diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 034fbeec6b..8542e10e08 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -267,6 +267,11 @@ jobs: node-version: 22 cache: pnpm + - name: Setup Python (bundled loopx CLI) + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: diff --git a/.github/workflows/nightly-artifacts.yml b/.github/workflows/nightly-artifacts.yml index e9f22ef1fc..0b72369bf9 100644 --- a/.github/workflows/nightly-artifacts.yml +++ b/.github/workflows/nightly-artifacts.yml @@ -142,6 +142,11 @@ jobs: node-version: 22 package-manager-cache: false + - name: Setup Python (bundled loopx CLI) + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: diff --git a/.gitignore b/.gitignore index d8159bba7b..98dcc459eb 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,9 @@ external/ .design/ .pnpm-store/ +# Generated by scripts/build-loopx.mjs (never commit the compiled binary) +src/apps/desktop/resources/loopx/ + # KMP shared mobile core and the platform apps that include it (Gradle). # Written per-directory rather than as a bare `local.properties` so the pattern # cannot accidentally hide a checked-in file elsewhere in the repo. local.properties diff --git a/Cargo.lock b/Cargo.lock index 9f7cca751c..50ac050c55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6200,6 +6200,7 @@ dependencies = [ "hex", "hmac", "log", + "regex", "semver", "serde", "serde_json", @@ -6406,6 +6407,7 @@ dependencies = [ "chrono", "dirs 6.0.0", "dunce", + "encoding_rs", "fs2", "futures", "futures-util", diff --git a/MiniApp/Demo/git-graph/README.md b/MiniApp/Demo/git-graph/README.md index 84070c1dbf..b579c990df 100644 --- a/MiniApp/Demo/git-graph/README.md +++ b/MiniApp/Demo/git-graph/README.md @@ -28,7 +28,7 @@ This demo showcases OpenBitFun MiniApp's full-stack collaboration capability — 1. **UI → Bridge**: `app.call('git.log', { cwd, maxCount })` etc. via `window.app` (JSON-RPC) 2. **Bridge → Tauri**: postMessage intercepted by the host `useMiniAppBridge`, which calls `miniapp_worker_call` 3. **Tauri → Worker**: Rust writes the request to Worker stdin (JSON-RPC) -4. **Worker**: `worker_host.js` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package +4. **Worker**: `worker_host.cjs` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package 5. **Worker → Tauri → Bridge → UI**: response travels back via stderr → Rust → postMessage to iframe → UI refreshes graph and detail panel ### Directory Structure @@ -117,7 +117,7 @@ miniapps/git-graph/ 1. **UI → Bridge**:`app.call('git.log', { cwd, maxCount })` 等通过 `window.app` 发起 RPC 2. **Bridge → Tauri**:postMessage 被宿主 `useMiniAppBridge` 接收,调用 `miniapp_worker_call` 3. **Tauri → Worker**:Rust 将请求写入 Worker 进程 stdin(JSON-RPC) -4. **Worker**:`worker_host.js` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 +4. **Worker**:`worker_host.cjs` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 5. **Worker → Tauri → Bridge → UI**:响应经 stderr 回传 Rust,再 postMessage 回 iframe,UI 更新图谱与详情 ### 目录结构 diff --git a/MiniApp/Skills/miniapp-dev/SKILL.md b/MiniApp/Skills/miniapp-dev/SKILL.md index db6b79d124..96089dfb57 100644 --- a/MiniApp/Skills/miniapp-dev/SKILL.md +++ b/MiniApp/Skills/miniapp-dev/SKILL.md @@ -121,10 +121,10 @@ src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.tsx # InitMiniAppDispla ### Worker 宿主 ``` -src/apps/desktop/resources/worker_host.js +src/apps/desktop/resources/worker_host.cjs ``` -Node/Bun 标准脚本:从 argv 读策略 JSON,stdin 收 RPC、stderr 回响应,内置 fs/shell/net/os/storage dispatch + 加载用户 `source/worker.js` 自定义方法。 +Node/Bun 标准脚本:从 `BITFUN_WORKER_POLICY` 环境变量读策略 JSON(argv[2] 仅作手动运行兜底),stdin 收 RPC、stderr 回响应,内置 fs/shell/net/os/storage dispatch + 加载用户 `source/worker.js` 自定义方法。 ## MiniApp 数据模型 (V2) @@ -217,6 +217,14 @@ MiniApp 框架**只暴露下列能力**,没有任何"通用 OpenBitFun 后端 > 维护者:以后若新增 `app.openbitfun.*` / `app.workspace.*` 这类宿主直通通道,请同步更新本节,避免"文档说没有、代码偷偷加了"的不一致。 +### 内置产品私有扩展 + +源码、来源和运行域都由宿主验证的内置产品界面可以获得私有 namespace,但它不属于 +MiniApp 公共 API,也不会注入普通、导入或市场 MiniApp。当前仅 +`builtin-bitfun-loopx` 使用私有 `app.loopx` 连接持久宿主控制器;每次调用仍由宿主 +复核原始 bundle、非 draft、非本地覆盖和本地执行域。生成 MiniApp 不得探测、声明或 +模拟这些私有 namespace;需要复用的能力必须先形成产品无关、带权限合同的公开 API。 + ## window.app 运行时 API MiniApp UI 内通过 **window.app** 访问: diff --git a/MiniApp/Skills/miniapp-dev/api-reference.md b/MiniApp/Skills/miniapp-dev/api-reference.md index 846faa06f2..bbdc402aa0 100644 --- a/MiniApp/Skills/miniapp-dev/api-reference.md +++ b/MiniApp/Skills/miniapp-dev/api-reference.md @@ -115,6 +115,16 @@ app.platform // 'win32' | 'darwin' | 'linux' app.mode // 'hosted' ``` +### 内置产品私有扩展不属于公共 API + +宿主可以为源码和来源均通过校验的内置产品界面注入私有 namespace。此类 namespace +不会进入普通或市场 MiniApp 的编译结果,也不属于 `window.app` 公共能力合同。 +当前 `builtin-bitfun-loopx` 使用私有 `app.loopx` 连接持久宿主控制器;宿主在每次调用时 +还会校验内置 id、原始 bundle 内容、非 draft/非本地覆盖状态和执行域。 + +生成、导入和市场 MiniApp 不得声明、探测或依赖 `app.loopx`,也不得以自定义 Worker +模拟该控制器。需要类似能力时应先建立新的公开、产品无关且有权限合同的 MiniApp API。 + ### `app.fs.*` — 文件系统 需在 `permissions.fs` 中声明读写范围。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index cdbaadee1b..1b95a97761 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -67,3 +67,24 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## loopx + +- Project: loopx +- Source: https://github.com/huangruiteng/loopx +- License: Apache-2.0 +- Copyright: Copyright 2026 LoopX contributors + +BitFun bundles a compiled, self-contained build of the loopx CLI as a desktop +sidecar resource (`resources/loopx/`). It powers the built-in bitfun-loopx +MiniApp's issue-fixing loop and is built at packaging time by +`scripts/build-loopx.mjs` from the pinned upstream release recorded in +`resources/loopx/manifest.json` (version, commit, content hash, and build +toolchain). The upstream Apache-2.0 license, NOTICE, historical MIT license, and +trademark policy ship alongside the binary as `resources/loopx/LICENSE`, +`resources/loopx/NOTICE`, `resources/loopx/LICENSE-MIT`, and +`resources/loopx/TRADEMARKS.md` in binary release packages. When the bundled +sidecar is unavailable, the local Desktop may download the pinned source tag +into BitFun-managed storage; that checkout retains the same upstream compliance +files. The `loopx` name is used descriptively to refer to the upstream project; +bitfun-loopx is a third-party integration and is not a LoopX project release. diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 09eac55a30..cfbdb1dc00 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **666** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **673** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **666** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **673** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index b44ab57775..02382259f9 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -2,12 +2,12 @@ "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", "catalogDigest": "d7a7419ddd673eb733ae8bd33dbb3dcd3b4d1ce0acfc953067a3403fe26d6699", - "commandCount": 666, + "commandCount": 673, "coverage": { - "commandCount": 666, + "commandCount": 673, "documentedCommandCount": 633, - "implementationCommandCount": 33, - "implementationDigest": "35539d9c1510287cb47f4a68fe35859b78f93bb06cd66b86e58d878a48d8c509" + "implementationCommandCount": 40, + "implementationDigest": "c0a1edc760154624bd17f742f82fb66d08af6e8e2b32e99829e92ed6a75cd75e" }, "commands": [ { @@ -5766,6 +5766,104 @@ "signature": "fn miniapp_install_deps( state: State<'_, AppState>, app_id: String, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, + { + "id": "miniapp_loopx_action", + "moduleId": "miniapp_loopx", + "capabilityId": "feature.miniapps", + "capabilityIds": [ + "feature.miniapps" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::miniapp_loopx_api::miniapp_loopx_action", + "sourceFile": "src/apps/desktop/src/api/miniapp_loopx_api.rs", + "signature": "fn miniapp_loopx_action( app_state: State<'_, AppState>, controller: State<'_, LoopxControllerState>, request: MiniAppLoopxActionRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, + { + "id": "miniapp_loopx_attach", + "moduleId": "miniapp_loopx", + "capabilityId": "feature.miniapps", + "capabilityIds": [ + "feature.miniapps" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::miniapp_loopx_api::miniapp_loopx_attach", + "sourceFile": "src/apps/desktop/src/api/miniapp_loopx_api.rs", + "signature": "fn miniapp_loopx_attach( app_state: State<'_, AppState>, controller: State<'_, LoopxControllerState>, request: MiniAppLoopxAttachRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, + { + "id": "miniapp_loopx_create_task", + "moduleId": "miniapp_loopx", + "capabilityId": "feature.miniapps", + "capabilityIds": [ + "feature.miniapps" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::miniapp_loopx_api::miniapp_loopx_create_task", + "sourceFile": "src/apps/desktop/src/api/miniapp_loopx_api.rs", + "signature": "fn miniapp_loopx_create_task( app_state: State<'_, AppState>, controller: State<'_, LoopxControllerState>, request: MiniAppLoopxCreateTaskRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, + { + "id": "miniapp_loopx_events_since", + "moduleId": "miniapp_loopx", + "capabilityId": "feature.miniapps", + "capabilityIds": [ + "feature.miniapps" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::miniapp_loopx_api::miniapp_loopx_events_since", + "sourceFile": "src/apps/desktop/src/api/miniapp_loopx_api.rs", + "signature": "fn miniapp_loopx_events_since( app_state: State<'_, AppState>, controller: State<'_, LoopxControllerState>, request: MiniAppLoopxEventsSinceRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, + { + "id": "miniapp_loopx_list_models", + "moduleId": "miniapp_loopx", + "capabilityId": "feature.miniapps", + "capabilityIds": [ + "feature.miniapps" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::miniapp_loopx_api::miniapp_loopx_list_models", + "sourceFile": "src/apps/desktop/src/api/miniapp_loopx_api.rs", + "signature": "fn miniapp_loopx_list_models( app_state: State<'_, AppState>, request: MiniAppLoopxListModelsRequest, ) -> Result, String>", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, + { + "id": "miniapp_loopx_resolve_intake", + "moduleId": "miniapp_loopx", + "capabilityId": "feature.miniapps", + "capabilityIds": [ + "feature.miniapps" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::miniapp_loopx_api::miniapp_loopx_resolve_intake", + "sourceFile": "src/apps/desktop/src/api/miniapp_loopx_api.rs", + "signature": "fn miniapp_loopx_resolve_intake( app_state: State<'_, AppState>, controller: State<'_, LoopxControllerState>, request: MiniAppLoopxResolveIntakeRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, + { + "id": "miniapp_loopx_turn_output_since", + "moduleId": "miniapp_loopx", + "capabilityId": "feature.miniapps", + "capabilityIds": [ + "feature.miniapps" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::miniapp_loopx_api::miniapp_loopx_turn_output_since", + "sourceFile": "src/apps/desktop/src/api/miniapp_loopx_api.rs", + "signature": "fn miniapp_loopx_turn_output_since( app_state: State<'_, AppState>, controller: State<'_, LoopxControllerState>, request: MiniAppLoopxTurnOutputSinceRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, { "id": "miniapp_market_auth_poll", "moduleId": "miniapp_market", diff --git a/package.json b/package.json index 0e7048af6a..577bf22ced 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "verify:webkit-compatibility": "node scripts/verify-webkit-compatibility.cjs", "verify:webkit-compatibility:test": "node --test scripts/verify-webkit-compatibility.test.mjs", "build:web": "pnpm run appearance:contract-audit && node scripts/build-web-parallel.mjs && node scripts/generate-frontend-revision.mjs && pnpm run verify:monaco-assets && pnpm run verify:webkit-compatibility", + "build:loopx": "node scripts/build-loopx.mjs", "build:mobile-web": "pnpm --dir src/mobile-web build", "build:miniapp-market": "pnpm --dir src/miniapp-market-web build", "type-check:miniapp-market": "pnpm --dir src/miniapp-market-web type-check", diff --git a/scripts/build-loopx.mjs b/scripts/build-loopx.mjs new file mode 100644 index 0000000000..0fab266c7d --- /dev/null +++ b/scripts/build-loopx.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +// Build the bundled loopx CLI for the OpenBitFun desktop installer. +// +// Runs at BUILD time only (CI / packaging), never on user machines: fetches the +// pinned loopx source, compiles a self-contained onefile binary with +// PyInstaller, and stages it under src/apps/desktop/resources/loopx/ together +// with the compliance artifacts (Apache-2.0 LICENSE/NOTICE, historical +// LICENSE-MIT, TRADEMARKS.md, provenance +// manifest). The desktop bundles that directory as a sidecar resource and the +// bitfun-loopx MiniApp worker prefers the bundled binary at runtime, so end +// users need neither Python nor git nor network access to use loopx. +// +// loopx v0.5.1 is Apache-2.0 (Copyright 2026 LoopX contributors), pure-stdlib Python +// >= 3.11; PyInstaller's bootloader exception permits the bundled binary. + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Keep in sync with the pin constants in openbitfun-services-integrations::miniapp::loopx_cli (LOOPX_PINNED_VERSION_TAG / LOOPX_PINNED_SOURCE_COMMIT): +// loopx's CLI JSON contract is the app's interface surface, so the bundled +// binary and the runtime vendor fallback must pin the same version. +export const LOOPX_VERSION = 'v0.5.1'; +const LOOPX_REPO = 'https://github.com/huangruiteng/loopx.git'; +const LOOPX_COMMIT = '1bb42f4cb3e329dcb71c64654228f951098cead1'; +const OUT_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'src', + 'apps', + 'desktop', + 'resources', + 'loopx', +); + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + buildLoopx().catch((err) => { + console.error(`build-loopx failed: ${err.message}`); + process.exit(1); + }); +}function sh(cmd, args, opts = {}) { + execFileSync(cmd, args, { stdio: 'inherit', ...opts }); +} + +function shOut(cmd, args, opts = {}) { + return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }) + .toString() + .trim(); +} + +function sha256Of(file) { + return createHash('sha256').update(readFileSync(file)).digest('hex'); +} + +function pickPython() { + for (const candidate of [process.env.PYTHON, 'python', 'python3'].filter(Boolean)) { + try { + const version = shOut(candidate, ['--version']); + const m = version.match(/Python\s+(\d+)\.(\d+)/); + if (m && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 11))) { + return { exe: candidate, version: version.replace(/\s+/g, ' ').trim() }; + } + console.warn(`build-loopx: ${candidate} is ${version} (Python >= 3.11 required), skipping`); + } catch { + // not installed / not on PATH + } + } + throw new Error('Python >= 3.11 not found (set PYTHON to a usable interpreter)'); +} + +export async function buildLoopx({ + version = LOOPX_VERSION, + outDir = OUT_DIR, +} = {}) { + const python = pickPython(); + console.log(`build-loopx: python ${python.version} (${python.exe})`); + try { + shOut('git', ['--version']); + } catch { + throw new Error('git not found on PATH'); + } + + const work = mkdtempSync(path.join(tmpdir(), 'loopx-build-')); + const src = path.join(work, 'src'); + const venv = path.join(work, 'venv'); + const dist = path.join(work, 'dist'); + try { + console.log(`build-loopx: cloning ${LOOPX_REPO} @ ${version}`); + sh('git', ['clone', '--depth', '1', '--branch', version, LOOPX_REPO, src]); + const commit = shOut('git', ['-C', src, 'rev-parse', 'HEAD']); + if (commit !== LOOPX_COMMIT) { + throw new Error(`pinned commit mismatch: expected ${LOOPX_COMMIT}, checkout is ${commit}`); + } + const described = shOut('git', ['-C', src, 'describe', '--tags', '--exact-match']); + if (described !== version) { + throw new Error(`pinned tag mismatch: expected ${version}, checkout is ${described}`); + } + if ( + !existsSync(path.join(src, 'LICENSE')) + || !existsSync(path.join(src, 'NOTICE')) + || !existsSync(path.join(src, 'LICENSE-MIT')) + || !existsSync(path.join(src, 'loopx', 'entrypoint.py')) + ) { + throw new Error('checkout is missing compliance files or loopx/entrypoint.py'); + } + + console.log('build-loopx: creating build venv and installing PyInstaller'); + sh(python.exe, ['-m', 'venv', venv]); + const pip = process.platform === 'win32' + ? path.join(venv, 'Scripts', 'pip.exe') + : path.join(venv, 'bin', 'pip'); + const pyinstaller = process.platform === 'win32' + ? path.join(venv, 'Scripts', 'pyinstaller.exe') + : path.join(venv, 'bin', 'pyinstaller'); + sh(pip, ['install', '--disable-pip-version-check', '--quiet', 'pyinstaller']); + + const entry = path.join(src, '_loopx_bundle_entry.py'); + writeFileSync(entry, 'from loopx.entrypoint import main\nraise SystemExit(main())\n', 'utf8'); + + console.log('build-loopx: compiling onefile binary (PyInstaller)'); + sh(pyinstaller, [ + '--onefile', + '--name', 'loopx', + '--clean', + '--noconfirm', + '--distpath', dist, + '--workpath', path.join(work, 'build'), + '--specpath', path.join(work, 'build'), + path.basename(entry), + ], { cwd: src }); + + const binary = path.join(dist, process.platform === 'win32' ? 'loopx.exe' : 'loopx'); + if (!existsSync(binary)) throw new Error(`PyInstaller produced no binary at ${binary}`); + + console.log('build-loopx: staging into', outDir); + mkdirSync(outDir, { recursive: true }); + copyFileSync(binary, path.join(outDir, path.basename(binary))); + copyFileSync(path.join(src, 'LICENSE'), path.join(outDir, 'LICENSE')); + copyFileSync(path.join(src, 'NOTICE'), path.join(outDir, 'NOTICE')); + copyFileSync(path.join(src, 'LICENSE-MIT'), path.join(outDir, 'LICENSE-MIT')); + copyFileSync(path.join(src, 'TRADEMARKS.md'), path.join(outDir, 'TRADEMARKS.md')); + + const pyinstallerVersion = shOut(pyinstaller, ['--version']); + const manifest = { + schema_version: 1, + name: 'loopx', + version, + source: LOOPX_REPO.replace(/\.git$/, ''), + commit, + license: 'Apache-2.0', + copyright: 'Copyright 2026 LoopX contributors', + sha256: `sha256:${sha256Of(path.join(outDir, path.basename(binary)))}`, + built_with: { + python: python.version, + pyinstaller: pyinstallerVersion, + }, + built_at: new Date().toISOString(), + }; + writeFileSync( + path.join(outDir, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + 'utf8', + ); + + const sizeMb = (statSync(path.join(outDir, path.basename(binary))).size / 1048576).toFixed(1); + console.log(`build-loopx: done — ${path.join(outDir, path.basename(binary))} (${sizeMb} MiB, loopx ${version} @ ${commit})`); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 9a6d82aee3..f99c5a72a1 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -920,6 +920,11 @@ test('contract and AI adapter tests keep reviewed feature and failure-domain top path: 'tests/miniapp_contracts.rs', requiredFeatures: ['miniapp'], }, + { + name: 'loopx_contracts', + path: 'tests/loopx_contracts.rs', + requiredFeatures: ['miniapp'], + }, { name: 'plugin_source_contracts', path: 'tests/plugin_source_contracts.rs', diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 04cb9911e3..81b67d0ce0 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -18,6 +18,9 @@ const SKIPPED_DIRECTORIES = new Set([ '.targets', '.tmp', '.worktrees', + // Local-only worktree roots (git-ignored); their manifests are historical + // snapshots and must not participate in boundary checks. + 'BitFun-worktrees', 'node_modules', 'target', ]); @@ -141,6 +144,7 @@ const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([ ['file-watch', ['rt', 'sync']], ['function-agents', ['fs', 'io-util', 'macros', 'rt', 'time']], ['mcp', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']], + ['miniapp-loopx', ['fs', 'io-util', 'macros', 'process', 'rt', 'sync', 'time']], ['miniapp-runtime', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']], ['miniapp-market', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']], ['plugin-source', ['fs', 'rt', 'sync', 'time']], @@ -1018,6 +1022,7 @@ export function findServicesIntegrationsReqwestFeatureViolations(pkg) { ['announcement', ['reqwest/json']], ['browser-control', ['reqwest/json']], ['mcp', ['reqwest/json', 'reqwest/stream']], + ['miniapp-loopx', ['reqwest/json']], ['miniapp-market', ['reqwest/json', 'reqwest/query', 'reqwest/stream']], ['miniapp-runtime', ['reqwest/stream']], ['models-dev', ['reqwest/system-proxy']], diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index 50e4e2cc54..fcf7360e1f 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -54,6 +54,11 @@ export const servicesIntegrationsIntegrationTestTargets = [ { name: 'file_watch_contracts', path: 'tests/file_watch_contracts.rs' }, { name: 'function_agent_contracts', path: 'tests/function_agent_contracts.rs' }, { name: 'git_contracts', path: 'tests/git_contracts.rs' }, + { + name: 'miniapp_loopx_contracts', + path: 'tests/miniapp_loopx_contracts.rs', + requiredFeatures: ['miniapp-loopx'], + }, { name: 'mcp_contracts', path: 'tests/mcp_contracts.rs' }, { name: 'mcp_streamable_http_contracts', path: 'tests/mcp_streamable_http_contracts.rs' }, { name: 'remote_connect_contracts', path: 'tests/remote_connect_contracts.rs' }, @@ -201,6 +206,11 @@ export const productDomainsIntegrationTestTargets = [ path: 'tests/miniapp_contracts.rs', requiredFeatures: ['miniapp'], }, + { + name: 'loopx_contracts', + path: 'tests/loopx_contracts.rs', + requiredFeatures: ['miniapp'], + }, { name: 'plugin_source_contracts', path: 'tests/plugin_source_contracts.rs', diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index bd9ae23f97..d40c55993d 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -4,6 +4,7 @@ export const servicesReqwestOwnerFeatures = [ 'announcement', 'browser-control', 'mcp', + 'miniapp-loopx', 'miniapp-market', 'miniapp-runtime', 'models-dev', @@ -262,7 +263,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'anyhow', ownerFeatures: ['browser-control', 'deep-research', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] }, { depName: 'async-trait', - ownerFeatures: ['deep-research', 'git', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'web-tools', 'workspace-search'], + ownerFeatures: ['deep-research', 'git', 'mcp', 'miniapp-loopx', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'web-tools', 'workspace-search'], }, { depName: 'base64', @@ -274,7 +275,7 @@ export const optionalDependencyFeatureOwnerRules = [ depName: 'openbitfun-core-types', ownerFeatures: ['deep-research', 'remote-connect', 'speech'], }, - { depName: 'openbitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'plugin-source'] }, + { depName: 'openbitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'hook-import', 'miniapp-loopx', 'miniapp-market', 'miniapp-runtime', 'plugin-source'] }, { depName: 'openbitfun-runtime-ports', ownerFeatures: ['deep-research', 'git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime', 'web-tools'] }, { depName: 'openbitfun-services-core', @@ -284,6 +285,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'git', 'hook-import', 'mcp', + 'miniapp-loopx', 'miniapp-market', 'miniapp-runtime', 'models-dev', @@ -300,12 +302,12 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bzip2', ownerFeatures: ['speech'] }, { depName: 'chrono', ownerFeatures: ['git', 'miniapp-market', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools'] }, { depName: 'dirs', ownerFeatures: ['browser-control', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete'] }, - { depName: 'dunce', ownerFeatures: ['plugin-source', 'workspace-search'] }, + { depName: 'dunce', ownerFeatures: ['miniapp-loopx', 'plugin-source', 'workspace-search'] }, { depName: 'fs2', ownerFeatures: ['plugin-source'] }, { depName: 'futures', ownerFeatures: ['mcp', 'remote-connect', 'review-platform'] }, { depName: 'futures-util', ownerFeatures: ['speech', 'web-tools'] }, { depName: 'git2', ownerFeatures: ['git'] }, - { depName: 'hex', ownerFeatures: ['hook-import', 'mcp', 'miniapp-market', 'plugin-source', 'remote-connect'] }, + { depName: 'hex', ownerFeatures: ['hook-import', 'mcp', 'miniapp-loopx', 'miniapp-market', 'plugin-source', 'remote-connect'] }, { depName: 'hostname', ownerFeatures: ['remote-connect'] }, { depName: 'image', ownerFeatures: ['miniapp-market', 'remote-connect'] }, { depName: 'local-ip-address', ownerFeatures: ['remote-connect'] }, @@ -327,19 +329,19 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'rustls', ownerFeatures: ['remote-connect'] }, { depName: 'rustls-native-certs', ownerFeatures: ['remote-connect'] }, { depName: 'schannel', ownerFeatures: ['remote-connect'] }, - { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'hook-import', 'mcp', 'miniapp-market', 'models-dev', 'plugin-source', 'remote-connect', 'remote-ssh', 'review-platform', 'speech'] }, + { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'hook-import', 'mcp', 'miniapp-loopx', 'miniapp-market', 'models-dev', 'plugin-source', 'remote-connect', 'remote-ssh', 'review-platform', 'speech'] }, { depName: 'sherpa-onnx', ownerFeatures: ['speech'] }, { depName: 'shellexpand', ownerFeatures: ['remote-ssh-concrete'] }, { depName: 'sse-stream', ownerFeatures: ['mcp'] }, { 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', 'miniapp-loopx', 'miniapp-market', 'plugin-source', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools', 'workspace-search'] }, { depName: 'tokio-tungstenite', ownerFeatures: ['remote-connect', 'speech-realtime'] }, - { depName: 'tokio-util', ownerFeatures: ['remote-ssh', 'speech'] }, + { depName: 'tokio-util', ownerFeatures: ['miniapp-loopx', 'remote-ssh', 'speech'] }, { depName: 'urlencoding', ownerFeatures: ['canvas-runtime', 'miniapp-market', 'remote-connect', 'review-platform'] }, - { depName: 'uuid', ownerFeatures: ['canvas-runtime', '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: 'uuid', ownerFeatures: ['canvas-runtime', 'hook-import', 'miniapp-loopx', 'miniapp-runtime', 'plugin-source', 'remote-connect', 'remote-ssh-concrete', 'speech'] }, + { depName: 'which', ownerFeatures: ['miniapp-loopx', 'miniapp-runtime', 'remote-connect', 'script-tool-runtime', 'workspace-search'] }, { depName: 'windows', ownerFeatures: ['models-dev', 'plugin-source', 'review-platform'] }, { depName: 'x25519-dalek', ownerFeatures: ['remote-connect'] }, ], @@ -1728,6 +1730,7 @@ export const ownerCrateFeatureAssemblyRules = [ 'git', 'hook-import', 'miniapp-runtime', + 'miniapp-loopx', 'mcp', 'models-dev', 'plugin-source', diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index c2196f9d78..c73fccd481 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -13,6 +13,7 @@ import { statSync, writeFileSync, } from 'fs'; +import { buildLoopx } from './build-loopx.mjs'; import { extractProductConfigArg } from './product-customization/cli.mjs'; import { productBuildEnvironment } from './product-customization/projections.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; @@ -55,6 +56,7 @@ async function main() { preparePluginHost(); // Flashgrep distribution is temporarily suspended. const flashgrepBinary = null; + const loopxResourceDir = await prepareBundledLoopx(forward, desktopDir); // Tauri CLI reads CI and rejects numeric "1" (common in CI providers). process.env.CI = 'true'; if (process.platform === 'darwin' && requestsDmgBundle(forward)) { @@ -66,6 +68,7 @@ async function main() { const tauriConfig = prepareTauriConfig(join(desktopDir, 'tauri.conf.json'), { desktopDir, flashgrepBinary, + loopxResourceDir, resolution, releaseChannel, }); @@ -253,7 +256,7 @@ export function prepareMacOSFlashgrepForSigning( export function prepareTauriConfig( baseConfigPath, - { desktopDir, flashgrepBinary, resolution, releaseChannel } + { desktopDir, flashgrepBinary, loopxResourceDir, resolution, releaseChannel } ) { const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); if (resolution) { @@ -265,6 +268,7 @@ export function prepareTauriConfig( config.identifier = resolution.assembly.bundleId; } injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary); + injectLoopxResource(config, loopxResourceDir); // The DeepSeek bridge is not a compile-time resource: cargo check and // desktop:dev must not require packages/dsh-acp/dist-profile. Official // packaging injects it here; frontend:build-all (beforeBuildCommand) @@ -368,6 +372,38 @@ function injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary) { }; } +// The compiled loopx CLI sidecar is staged under resources/loopx/ and injected +// only when a real bundle is requested. tauri-build validates every resource +// path at cargo-build time, so a missing directory must never appear in the +// generated config. Desktop dev keeps the same layout via ensureLoopxSidecar +// in scripts/dev.cjs; when the sidecar is absent the runtime degrades to the +// fixed system `loopx` command (ExactPinned) instead of vendor/pip. +function injectLoopxResource(config, loopxResourceDir) { + const resources = { ...(config.bundle?.resources || {}) }; + delete resources['resources/loopx/']; + if (loopxResourceDir) { + resources['resources/loopx/'] = 'resources/loopx/'; + } + config.bundle = { + ...(config.bundle || {}), + resources, + }; +} + +async function prepareBundledLoopx(forwardArgs, desktopDir) { + if (forwardArgs.includes('--no-bundle')) { + return null; + } + console.log('[tauri-build] Building the bundled loopx CLI sidecar (scripts/build-loopx.mjs)'); + await buildLoopx(); + const loopxDir = join(desktopDir, 'resources', 'loopx'); + const bin = join(loopxDir, process.platform === 'win32' ? 'loopx.exe' : 'loopx'); + if (!existsSync(bin)) { + throw new Error(`bundled loopx CLI missing after build at ${bin}`); + } + return loopxDir; +} + function bundledFlashgrepResources(primaryBinary) { if (!primaryBinary) return []; const binaries = [primaryBinary]; diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 0826f10e22..1cb77d479d 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -402,7 +402,9 @@ test('official packaging injects the DeepSeek profile resource', () => { mkdirSync(fixture, { recursive: true }); const baseConfig = join(fixture, 'tauri.conf.json'); writeFileSync(baseConfig, JSON.stringify({ - bundle: { resources: { 'resources/worker_host.js': 'resources/worker_host.js' } }, + bundle: { resources: { + 'resources/worker_host.js': 'resources/worker_host.js', + } }, })); try { const generated = prepareTauriConfig(baseConfig, { diff --git a/scripts/dev.cjs b/scripts/dev.cjs index 9a48d5c484..92c8a4ea57 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -7,6 +7,7 @@ const fs = require('fs'); const net = require('net'); +const os = require('os'); const { execSync, spawn } = require('child_process'); const path = require('path'); const { pathToFileURL } = require('url'); @@ -150,15 +151,21 @@ function spawnCommand(cmd, args, cwd = ROOT_DIR, envOverrides = {}, shell = fals */ function runCommandPrefixed(prefix, cmd, args, cwd = ROOT_DIR, envOverrides = {}) { return new Promise((resolve) => { - const child = spawn(cmd, args, { + const spawnOptions = { cwd, - shell: process.platform === 'win32', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...envOverrides, }, - }); + }; + const child = process.platform === 'win32' + ? spawn( + process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe', + ['/d', '/s', '/c', cmd, ...args], + spawnOptions, + ) + : spawn(cmd, args, spawnOptions); const forward = (stream, out) => { let buffered = ''; @@ -327,7 +334,7 @@ async function runDesktopTargetGc(profile = 'debug') { async function rebuildDesktopDebugBinary() { const buildEnv = { ...process.env, - CARGO_PROFILE_DEV_DEBUG: process.env.CARGO_PROFILE_DEV_DEBUG || '0', + CARGO_PROFILE_DEV_DEBUG: process.env.CARGO_PROFILE_DEV_DEBUG || 'line-tables-only', CARGO_PROFILE_DEV_INCREMENTAL: process.env.CARGO_PROFILE_DEV_INCREMENTAL || 'true', CARGO_PROFILE_DEV_CODEGEN_UNITS: process.env.CARGO_PROFILE_DEV_CODEGEN_UNITS || '256', }; @@ -553,8 +560,20 @@ async function startDesktopPreview() { printInfo(`Launching debug desktop binary: ${desktopBinary}`); + // Dev builds must never share the user data home with an installed (or any + // other) OpenBitFun build: durable stores like the agent coordination SQLite + // carry schema versions, and a newer build upgrading the shared database + // hard-rejects older builds (observed as every LoopX task entering + // recovery). `OPENBITFUN_USER_ROOT` is the documented data-root override; + // point it at the dedicated dev data home so cross-build schema collisions + // are structurally impossible. E2E runs use their own guarded roots and are + // unaffected. + const devUserRoot = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'com.openbitfun.desktop.dev', 'openbitfun'); + printInfo(`Dev data root (OPENBITFUN_USER_ROOT): ${devUserRoot}`); + appProcess = spawnBackgroundCommand(desktopBinary, [], ROOT_DIR, { ...process.env, + OPENBITFUN_USER_ROOT: process.env.OPENBITFUN_USER_ROOT || devUserRoot, // Debug previews must upload the current workspace build. The adjacent // target/debug resource tree is only a build-time copy and can lag behind // mobile-web edits made while the desktop binary is being reused. @@ -579,6 +598,55 @@ async function startDesktopPreview() { await new Promise(() => {}); } +/** + * Ensure the bundled, compiled loopx CLI sidecar exists for desktop dev. + * + * The runtime prefers this sidecar (`CARGO_MANIFEST_DIR/resources/loopx`, + * see desktop app_state::resolve_bundled_loopx_dir), so desktop:dev mirrors + * the packaging build instead of silently falling back to a system `loopx` + * command. The staged manifest.json carries the exact pin and a sha256 of the + * binary: when both match (and the checksum is intact) the build is skipped in + * seconds; a pin change or corruption triggers a rebuild. A build failure is a + * warning, never a dev-start blocker — the existing system-command fallback in + * loopx_cli.rs stays as the degraded path. + */ +async function ensureLoopxSidecar() { + const helperUrl = pathToFileURL(path.join(__dirname, 'build-loopx.mjs')).href; + const helper = await import(helperUrl); + const loopxDir = path.join(ROOT_DIR, 'src', 'apps', 'desktop', 'resources', 'loopx'); + const manifestPath = path.join(loopxDir, 'manifest.json'); + const binaryName = process.platform === 'win32' ? 'loopx.exe' : 'loopx'; + const binaryPath = path.join(loopxDir, binaryName); + + try { + if (fs.existsSync(manifestPath) && fs.existsSync(binaryPath)) { + const { createHash } = require('node:crypto'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const expected = (manifest.sha256 || '').replace(/^sha256:/, ''); + const actual = createHash('sha256').update(fs.readFileSync(binaryPath)).digest('hex'); + if (manifest.version === helper.LOOPX_VERSION && expected && actual === expected) { + printInfo( + `loopx sidecar up to date (v${helper.LOOPX_VERSION}${manifest.commit ? ` @ ${manifest.commit}` : ''})` + ); + return { ok: true, code: 0, error: null }; + } + printInfo('loopx sidecar pin or checksum changed; rebuilding...'); + } else { + printInfo( + 'loopx sidecar missing; building bundled CLI (first desktop:dev run may take a while)...' + ); + } + await helper.buildLoopx(); + printSuccess(`loopx sidecar ready (v${helper.LOOPX_VERSION})`); + return { ok: true, code: 0, error: null }; + } catch (error) { + printWarning( + `loopx sidecar build skipped (${error.message}); dev will fall back to a system loopx command` + ); + return { ok: true, code: 0, error: null }; + } +} + /** * Main entry */ @@ -608,7 +676,7 @@ async function main() { let currentStep = 1; // Step 1: Run all independent preparation tasks in parallel. - // copy-monaco / generate-version / mobile-web / plugin-host have no + // copy-monaco / generate-version / mobile-web / plugin-host / loopx have no // dependencies on each other; each task's output is line-prefixed so the // interleaved logs stay attributable. The DeepSeek bridge is not prepared // here: it is not a compile-time Tauri resource. Official desktop:build @@ -617,7 +685,7 @@ async function main() { currentStep++, totalSteps, desktopMode - ? 'Prepare resources (parallel: monaco, version, mobile-web, plugin-host)' + ? 'Prepare resources (parallel: monaco, version, mobile-web, plugin-host, loopx)' : 'Prepare resources (parallel: monaco, version)' ); @@ -643,6 +711,10 @@ async function main() { hint: 'Hint: install Bun, then run `pnpm run plugin-host:prepare`', promise: runCommandPrefixed('plugin-host', 'pnpm', ['run', 'plugin-host:prepare']), }); + prepTasks.push({ + name: 'Prepare loopx CLI sidecar', + promise: ensureLoopxSidecar(), + }); prepTasks.push({ name: 'Build mobile-web', promise: runCommandPrefixed('mobile-web', 'node', ['scripts/mobile-web-build.cjs', '--install']), diff --git a/src/apps/desktop/AGENTS-CN.md b/src/apps/desktop/AGENTS-CN.md index 2654f56db6..b7f79f2f2c 100644 --- a/src/apps/desktop/AGENTS-CN.md +++ b/src/apps/desktop/AGENTS-CN.md @@ -50,19 +50,38 @@ pnpm run prepare:dsh-profile # 可选:本地 DeepSeek Harness 会话 | 命令 | 使用场景 | |---|---| -| `pnpm run desktop:build:fast` | Debug 构建,不打包;手动测试时编译最快 | +| `pnpm run desktop:build:fast` | Debug 构建,不打包;用于编译验证。产物连 dev server 时 IPC 会被拒,见下方两种语义说明 | | `pnpm run desktop:build:release-fast` | 类 Release 构建,降低 LTO;需要 release 行为但无法等待完整 LTO 时使用 | | `pnpm run desktop:build:nsis:fast` | Windows 安装器,使用 `release-fast` profile;快速验证安装器 | 需要完整断点调试信息时设置 `CARGO_PROFILE_DEV_DEBUG=2`。默认 dev profile 保留行号信息, 同时减少 PDB 体积。 +### Debug 二进制有两种语义;desktop:build:fast 的产物连 dev server 时 IPC 全被拒 + +`target/debug/bitfun-desktop.exe` 因构建方式不同有两种 tauri 语义: + +- `cargo build -p bitfun-desktop`(`desktop:preview:debug` 内部重建也用这个):tauri dev 语义(`DEP_TAURI_DEV=true`),dev server origin `http://localhost:1422` 被信任,IPC 正常。 +- `desktop:build:fast` 执行 `tauri build`,会启用 `custom-protocol`:tauri production 语义,同一 origin 被视为 remote URL,ACL 拒绝所有 app 命令和 `plugin-log`。 + +Debug 构建总是导航到 `devUrl`(启动日志 `url_kind=external`),所以 `desktop:build:fast` 的产物 + dev server 会呈现"界面完整渲染但所有 invoke 被拒":`... not allowed. Plugin not found` 错误弹窗、会话列表加载失败、小应用列表为空(加载错误被吞成空列表)、会话日志目录里 `webview.log` 为 0 字节。不带 dev server 直接启动则表现为 `ERR_CONNECTION_REFUSED`。 + +`desktop:preview:debug` 按二进制 mtime 是否新于 tracked inputs 决定复用——`desktop:build:fast` 的产物同样会被复用。跑过 `desktop:build:fast` 之后,必须先 `cargo build -p bitfun-desktop`(或 `pnpm run desktop:preview:debug -- --force-rebuild`)再启动 preview,否则会复用坏二进制。 + +诊断捷径:UI 正常渲染 + `config/logs//` 下 `webview.log` 为 0 字节 = IPC 被 ACL 拒绝,是构建语义问题,不是数据问题;`BITFUN_USER_ROOT` 下的数据不受影响。 + +另外:内置 miniapp 资源(例如 `bitfun-loopx` 的 `ui.js`/`worker.js`)通过 `include_str!` 内嵌进 `openbitfun-product-domains`,改资源会连带重编 product-domains → assembly-core → desktop 链路,增量构建耗时几分钟属于正常。exe 自身报 `os error 5` 表示有实例仍在运行、exe 被锁定,见下方 GC 竞争一节。 + ## Target 缓存 GC `desktop:dev`(退出时)、`desktop:preview:debug`(关闭时)以及 `desktop:build*` 会裁剪过期的 `target/` 缓存代际。`incremental` 每个 crate/session 保留最新项;GC 根据 Cargo fingerprint JSON 区分 lib、test、bin、build-script 等构建单元,每个单元保留最新代际,并保留 Cargo 管理的 `invoked.timestamp` 在最近 24 小时内刷新过的全部代际,随后删除失去 fingerprint 的 `deps` 文件和 `build` 目录。忙碌检测只检查所选 profile 的 Cargo 锁文件,因此其他 worktree 的编译不会再阻止清理。手动执行:`pnpm run target:gc -- --profile debug`。禁用:`OPENBITFUN_TARGET_GC=0`;演练:`OPENBITFUN_TARGET_GC_DRY_RUN=1`;可用 `OPENBITFUN_TARGET_GC_MIN_AGE_HOURS` 调整安全窗口。 `release-fast` profile(`Cargo.toml`):继承 `release`,但关闭 LTO、`codegen-units` 提高到 16、启用增量编译。编译速度显著提升,代价是二进制体积增大和边际运行时性能下降。 +### 手动并发构建会与退出时 GC 竞争 + +杀掉 `bitfun-desktop.exe` 会结束 `desktop:dev` / `desktop:preview:debug` 会话,退出过程会执行 target GC。此时立即手动执行 `cargo build -p bitfun-desktop` 可能编译中途失败,报 `os error 3`(系统找不到指定的路径),原因是 GC 在构建写入时删除了 `target/debug/build` 或 `target/debug/incremental` 目录。`bitfun-desktop.exe` 自身报 `os error 5`(拒绝访问)则是应用仍在运行、exe 被锁定。两者都是暂时性的:确认 preview 会话(node `dev.cjs` + vite + exe)完全退出——杀掉 exe 后等几秒——然后直接重跑构建即可,无需 `cargo clean`。 + ## DevTools feature(模型规则) `devtools` Cargo feature 用于桌面端 UI/UX 调试。添加或修改调试相关代码时: diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 187f2ca93a..b54828e86a 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -67,6 +67,21 @@ pnpm run prepare:dsh-profile # optional: local DeepSeek Harness sessions Set `CARGO_PROFILE_DEV_DEBUG=2` when full breakpoint debug information is required. The default dev profile keeps line tables while reducing PDB size. +### Debug binaries have two semantics; a `desktop:build:fast` binary breaks IPC against the dev server + +`target/debug/bitfun-desktop.exe` can be built with two different tauri semantics: + +- `cargo build -p bitfun-desktop` (also what `desktop:preview:debug` builds internally): tauri dev semantics (`DEP_TAURI_DEV=true`). The dev server origin `http://localhost:1422` is trusted; IPC works. +- `desktop:build:fast` runs `tauri build`, which enables `custom-protocol`: tauri production semantics. The same origin is treated as a remote URL and the ACL denies every app command and `plugin-log`. + +Debug builds always navigate to `devUrl` (startup log `url_kind=external`), so running a `desktop:build:fast` binary against the dev server renders a fully working UI where every invoke is rejected: `... not allowed. Plugin not found` error toasts, session list failures, an empty miniapp catalog (the load error is swallowed into an empty list), and a 0-byte `webview.log` in the session log dir. Launching such a binary without the dev server shows `ERR_CONNECTION_REFUSED` instead. + +`desktop:preview:debug` reuses the existing binary whenever its mtime is newer than the tracked inputs — including a leftover `desktop:build:fast` binary. After running `desktop:build:fast`, run `cargo build -p bitfun-desktop` (or `pnpm run desktop:preview:debug -- --force-rebuild`) before the preview, or the broken binary is reused. + +Diagnosis shortcut: rendered UI + 0-byte `webview.log` under `config/logs//` means IPC was denied by the ACL — a build-semantics problem, not a data problem. Data under `BITFUN_USER_ROOT` is unaffected. + +Also note: builtin miniapp assets (for example the `bitfun-loopx` `ui.js`/`worker.js`) are embedded via `include_str!` into `openbitfun-product-domains`, so asset edits recompile the product-domains → assembly-core → desktop chain; several minutes for an incremental build is normal. `os error 5` on the exe itself means an instance is still running and locks it; see the GC-race section below. + ## Target cache GC `desktop:dev` (on exit), `desktop:preview:debug` (on shutdown), and `desktop:build*` prune stale `target/` cache generations. Incremental roots keep the latest crate/session. Cargo fingerprint JSON identifies distinct lib, test, bin, and build-script units; GC keeps the latest generation of each unit plus every generation whose Cargo-managed `invoked.timestamp` was refreshed within the last 24 hours, then removes orphaned `deps` files and `build` directories. Busy detection is scoped to Cargo lock files in the selected profile, so an unrelated worktree build does not suppress GC. Manual: `pnpm run target:gc -- --profile debug`. Disable with `OPENBITFUN_TARGET_GC=0`; dry-run with `OPENBITFUN_TARGET_GC_DRY_RUN=1`; adjust the grace window with `OPENBITFUN_TARGET_GC_MIN_AGE_HOURS`. diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 634c939780..4a7d3d619d 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -23,8 +23,8 @@ openbitfun-core = { path = "../../crates/assembly/core", features = ["product-fu openbitfun-relay-service = { path = "../../crates/services/relay-service" } openbitfun-agent-runtime = { path = "../../crates/execution/agent-runtime", features = ["agent-runtime"] } openbitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports", features = ["agent-api", "permission", "workspace-ports"] } -openbitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["appearance-market"] } -openbitfun-services-integrations = { path = "../../crates/services/services-integrations", features = ["canvas-runtime", "miniapp-market", "remote-ssh-concrete", "speech-realtime"] } +openbitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["appearance-market", "miniapp"] } +openbitfun-services-integrations = { path = "../../crates/services/services-integrations", features = ["canvas-runtime", "miniapp-market", "miniapp-loopx", "remote-ssh-concrete", "speech-realtime"] } openbitfun-core-types = { path = "../../crates/contracts/core-types" } openbitfun-agent-tools = { path = "../../crates/execution/tool-contracts", features = ["element-token"] } openbitfun-transport = { path = "../../crates/adapters/transport", features = ["tauri-adapter"] } diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index cc115b422c..c245daeb72 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -189,27 +189,44 @@ impl AppState { let worker_host_path = match resolve_worker_host_path() { Some(p) => { - log::info!("Resolved worker_host.js at: {}", p.display()); + log::info!("Resolved worker host at: {}", p.display()); p } None => { log::warn!( - "worker_host.js not found in any candidate location; \ + "worker host not found in any candidate location; \ MiniApp Workers will not start" ); std::path::PathBuf::from("worker_host.js") } }; + // The bitfun-loopx MiniApp prefers a bundled, compiled loopx CLI + // sidecar (scripts/build-loopx.mjs, shipped via bundle.resources). + // Export its resource directory to JS workers when present. The native + // LoopX controller separately owns managed GitHub source installation + // and the exact-version system fallback used in development. + let loopx_resource_dir = match resolve_bundled_loopx_dir() { + Some(dir) => { + log::info!("Resolved bundled loopx CLI resource dir: {}", dir.display()); + Some(dir) + } + None => { + log::info!( + "Bundled LoopX CLI not found; the native controller will use managed source or the exact-version system fallback" + ); + None + } + }; let speech_service = Arc::new(SpeechService::new(SpeechStoragePaths::new( path_manager.speech_models_dir(), path_manager.speech_model_downloads_dir(), path_manager.speech_input_temp_dir(), ))); - let js_worker_pool = JsWorkerPool::new(path_manager, worker_host_path) + let js_worker_pool = JsWorkerPool::new(path_manager, worker_host_path, loopx_resource_dir) .ok() .map(Arc::new); if js_worker_pool.is_none() { - log::warn!("JsWorkerPool not initialized (missing worker_host.js or no Bun/Node)"); + log::warn!("JsWorkerPool not initialized (missing worker host or no Bun/Node)"); } let statistics = Arc::new(RwLock::new(AppStatistics { @@ -574,6 +591,9 @@ impl AppState { /// 4. `/../Resources/worker_host.js` — flat macOS layout fallback. /// 5. `/../lib//resources/worker_host.js` — typical Linux deb/AppImage. /// 6. `/../share//resources/worker_host.js` — alt Linux layout. +/// `.cjs` (not `.js`): the host is CommonJS and must stay that way regardless of +/// the nearest `package.json` ("type": "module" in this repo's root would make +/// Node treat a `.js` host as ESM and crash on `require`). fn resolve_worker_host_path() -> Option { let mut candidates: Vec = Vec::new(); @@ -616,3 +636,44 @@ fn resolve_worker_host_path() -> Option { candidates.into_iter().find(|p| p.exists()) } + +/// Resolve the directory hosting the bundled, compiled loopx CLI sidecar +/// (built by `scripts/build-loopx.mjs`, shipped via `bundle.resources`). The +/// layouts mirror `resolve_worker_host_path`, with the platform binary name +/// under a `loopx/` subdirectory. Returns the resource directory so the +/// worker pool can export it as `BITFUN_RESOURCE_DIR`. +pub(crate) fn resolve_bundled_loopx_dir() -> Option { + let bin_name = if cfg!(windows) { "loopx.exe" } else { "loopx" }; + let mut candidates: Vec = Vec::new(); + + candidates.push( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("loopx"), + ); + + if let Ok(exe) = std::env::current_exe() { + if let Some(exe_dir) = exe.parent() { + candidates.push(exe_dir.join("resources").join("loopx")); + if let Some(parent) = exe_dir.parent() { + candidates.push(parent.join("Resources").join("resources").join("loopx")); + candidates.push(parent.join("Resources").join("loopx")); + if let Some(bin) = exe.file_name().and_then(|s| s.to_str()) { + candidates.push(parent.join("lib").join(bin).join("resources").join("loopx")); + candidates.push( + parent + .join("share") + .join(bin) + .join("resources") + .join("loopx"), + ); + } + } + } + } + + candidates + .into_iter() + .find(|dir| dir.join(bin_name).exists()) + .map(|dir| dir.parent().map(|p| p.to_path_buf()).unwrap_or(dir)) +} diff --git a/src/apps/desktop/src/api/miniapp_loopx_api.rs b/src/apps/desktop/src/api/miniapp_loopx_api.rs new file mode 100644 index 0000000000..79ed4df045 --- /dev/null +++ b/src/apps/desktop/src/api/miniapp_loopx_api.rs @@ -0,0 +1,290 @@ +use super::app_state::AppState; +use openbitfun_core::miniapp::ai_bridge::{ + available_models_for_permissions, MiniAppAiModelDescriptor, MiniAppAiModelInfo, +}; +use openbitfun_core::miniapp::{loopx::LoopxController, MiniAppCustomizationOriginKind, BUILTIN_APPS}; +use openbitfun_core::service::config::types::GlobalConfig; +use openbitfun_product_domains::miniapp::builtin::builtin_source_matches; +use openbitfun_product_domains::miniapp::loopx::{ + LoopxActionRequest, LoopxActionResponse, LoopxAttachRequest, LoopxAttachResponse, + LoopxCreateTaskRequest, LoopxCreateTaskResponse, LoopxEventsSinceRequest, + LoopxEventsSinceResponse, LoopxExecutionDomain, LoopxExecutionSupport, + LoopxResolveIntakeRequest, LoopxResolveIntakeResponse, LoopxTurnOutputSinceRequest, + LoopxTurnOutputSinceResponse, LOOPX_BUILTIN_APP_ID, +}; +use serde::Deserialize; +use std::sync::Arc; +use std::time::Instant; +use tauri::State; + +pub const LOOPX_UNSUPPORTED_EXECUTION_DOMAIN: &str = "unsupported_execution_domain"; + +pub struct LoopxControllerState { + pub controller: Arc, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxAttachRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxAttachRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxResolveIntakeRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxResolveIntakeRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxCreateTaskRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxCreateTaskRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxActionRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxActionRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxEventsSinceRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxEventsSinceRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxTurnOutputSinceRequest { + pub app_id: String, + #[serde(flatten)] + pub input: LoopxTurnOutputSinceRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppLoopxListModelsRequest { + pub app_id: String, +} + +async fn authorize_builtin(state: &AppState, app_id: &str) -> Result<(), String> { + if app_id != LOOPX_BUILTIN_APP_ID { + return Err("LoopX controller is available only to the built-in LoopX MiniApp".to_string()); + } + let builtin = BUILTIN_APPS + .iter() + .find(|app| app.id == LOOPX_BUILTIN_APP_ID) + .ok_or_else(|| "Built-in LoopX bundle is unavailable".to_string())?; + let app = state + .miniapp_manager + .get(app_id) + .await + .map_err(|error| format!("Failed to load built-in LoopX MiniApp: {error}"))?; + if !builtin_source_matches(&app.source, builtin) { + return Err("LoopX controller is disabled for modified MiniApp content".to_string()); + } + if let Some(metadata) = state + .miniapp_manager + .load_customization_metadata(app_id) + .await + .map_err(|error| format!("Failed to load LoopX customization metadata: {error}"))? + { + if metadata.local_override + || metadata.origin.kind != MiniAppCustomizationOriginKind::Builtin + || metadata.origin.builtin_id.as_deref() != Some(LOOPX_BUILTIN_APP_ID) + { + return Err("LoopX controller is disabled for a local MiniApp override".to_string()); + } + } + Ok(()) +} + +async fn is_remote_workspace(state: &AppState) -> bool { + state.remote_workspace.read().await.is_some() +} + +fn unsupported_error() -> String { + format!( + "{LOOPX_UNSUPPORTED_EXECUTION_DOMAIN}: LoopX currently supports only a local Desktop workspace" + ) +} + +/// List the host-configured chat models for the LoopX model picker. This is +/// gated on the same verified-builtin check as the controller bridge (not the +/// MiniApp AI permission), because the LoopX native agent selects the model. +#[tauri::command] +pub async fn miniapp_loopx_list_models( + app_state: State<'_, AppState>, + request: MiniAppLoopxListModelsRequest, +) -> Result, String> { + authorize_builtin(&app_state, &request.app_id).await?; + let global_config = app_state + .config_service + .get_config::(None) + .await + .map_err(|error| error.to_string())?; + let primary_id = global_config + .ai + .resolve_model_selection("primary") + .unwrap_or_default(); + let fast_id = global_config + .ai + .resolve_model_selection("fast") + .unwrap_or_default(); + let models = available_models_for_permissions( + global_config + .ai + .models + .iter() + .map(|model| MiniAppAiModelDescriptor { + id: model.id.clone(), + name: model.name.clone(), + model_name: model.model_name.clone(), + provider: model.provider.clone(), + enabled: model.enabled, + supports_text_chat: model.supports_text_generation(), + }), + &[], + &primary_id, + &fast_id, + ); + Ok(models) +} + +#[tauri::command] +pub async fn miniapp_loopx_attach( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxAttachRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Ok(controller + .controller + .attach( + LoopxExecutionDomain::RemoteWorkspace, + LoopxExecutionSupport::UnsupportedExecutionDomain, + Some(unsupported_error()), + ) + .await); + } + if request.input.resume_detected { + let resume_controller = controller.controller.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = resume_controller.handle_host_resume().await { + log::warn!("LoopX host resume reconciliation failed: {error}"); + } + }); + } + Ok(controller + .controller + .attach( + LoopxExecutionDomain::LocalDesktop, + LoopxExecutionSupport::Supported, + None, + ) + .await) +} + +#[tauri::command] +pub async fn miniapp_loopx_resolve_intake( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxResolveIntakeRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + controller.controller.resolve_intake(request.input).await +} + +#[tauri::command] +pub async fn miniapp_loopx_create_task( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxCreateTaskRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + controller.controller.create_tasks(request.input).await +} + +#[tauri::command] +pub async fn miniapp_loopx_action( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxActionRequest, +) -> Result { + let started_at = Instant::now(); + let action = request.input.action; + let request_id = request.input.client_request_id.clone(); + log::info!("LoopX action command received: action={action:?}, request_id={request_id}"); + let result = async { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + controller.controller.action(request.input).await + } + .await; + let duration_ms = openbitfun_core::util::elapsed_ms_u64(started_at); + match &result { + Ok(response) => log::info!( + "LoopX action command completed: action={action:?}, request_id={request_id}, status={:?}, duration_ms={duration_ms}", + response.status + ), + Err(error) => log::warn!( + "LoopX action command failed: action={action:?}, request_id={request_id}, duration_ms={duration_ms}, error={error}" + ), + } + result +} + +#[tauri::command] +pub async fn miniapp_loopx_events_since( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxEventsSinceRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + Ok(controller.controller.events_since(request.input).await) +} + +#[tauri::command] +pub async fn miniapp_loopx_turn_output_since( + app_state: State<'_, AppState>, + controller: State<'_, LoopxControllerState>, + request: MiniAppLoopxTurnOutputSinceRequest, +) -> Result { + authorize_builtin(&app_state, &request.app_id).await?; + if is_remote_workspace(&app_state).await { + return Err(unsupported_error()); + } + Ok(controller.controller.turn_output_since(request.input).await) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unsupported_execution_domain_is_stable() { + assert!(unsupported_error().starts_with(LOOPX_UNSUPPORTED_EXECUTION_DOMAIN)); + } +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 38bb045244..e38dd4d3b7 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -35,6 +35,7 @@ pub mod mcp_api; pub mod miniapp_agent_api; pub mod miniapp_api; pub mod miniapp_export_api; +pub mod miniapp_loopx_api; pub mod miniapp_market_api; pub mod pages_api; pub mod path_target; diff --git a/src/apps/desktop/src/crash_diagnostics.rs b/src/apps/desktop/src/crash_diagnostics.rs index d5890fd80e..a18766c6d2 100644 --- a/src/apps/desktop/src/crash_diagnostics.rs +++ b/src/apps/desktop/src/crash_diagnostics.rs @@ -94,6 +94,37 @@ struct DiagnosticMetadata { platform_crash_report_hints: Vec, } +/// Ensures a single GUI instance owns this data root. Two instances sharing +/// one root corrupt controller state and workspace lifecycle (for example +/// LoopX worktrees removed out from under live tasks). File locks are +/// released by the OS when the holder dies, so a crashed instance never +/// blocks the next launch. +pub fn acquire_single_instance_lock(session_log_dir: &Path) -> Result<(), String> { + let logs_root = session_log_dir.parent().unwrap_or(session_log_dir); + let lock_path = logs_root.join(".bitfun-instance.lock"); + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_path) + .map_err(|error| { + format!( + "failed to open the instance lock {}: {error}", + lock_path.display() + ) + })?; + file.try_lock().map_err(|_| { + format!( + "another BitFun instance is already running for this data root (lock: {}); close that instance before starting a new one", + lock_path.display() + ) + })?; + // Deliberately leak the handle: the lock must outlive every other use of + // this data root for the whole process lifetime. + std::mem::forget(file); + Ok(()) +} + pub fn initialize_run_state(session_log_dir: PathBuf, startup_trace_id: &str) { let logs_root = session_log_dir .parent() diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index d113f3e0d9..eb0689207b 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -589,6 +589,13 @@ pub async fn run() { }; startup_trace.record_phase("native_process_start", "native"); crash_diagnostics::initialize_run_state(session_log_dir.clone(), &startup_trace_id); + if let Err(error) = crash_diagnostics::acquire_single_instance_lock(&session_log_dir) { + // A second instance sharing this data root would corrupt controller + // state and workspace lifecycle; refuse to start instead. + eprintln!("BitFun desktop exited: {error}"); + log::error!("{error}"); + return; + } setup_panic_hook(); // Install the rustls ring CryptoProvider as the process-level default early, @@ -822,6 +829,88 @@ pub async fn run() { &path_manager.user_data_dir(), )); + let loopx_resource_dir = api::app_state::resolve_bundled_loopx_dir(); + let managed_loopx_source_dir = path_manager + .miniapp_dir(openbitfun_product_domains::miniapp::loopx::LOOPX_BUILTIN_APP_ID) + .join("runtime") + .join("loopx-source-v0.5.1"); + let mut loopx_cli_config = + openbitfun_services_integrations::miniapp::loopx_cli::LoopxCliAdapterConfig::packaged( + loopx_resource_dir.clone().unwrap_or_else(|| { + path_manager + .miniapp_dir(openbitfun_product_domains::miniapp::loopx::LOOPX_BUILTIN_APP_ID) + .join("missing-bundled-loopx") + }), + ) + .with_managed_source_dir(managed_loopx_source_dir); + if loopx_resource_dir.is_none() { + loopx_cli_config.system_fallback = + openbitfun_services_integrations::miniapp::loopx_cli::LoopxSystemFallbackPolicy::ExactPinned; + } + let loopx_cli_adapter = + openbitfun_services_integrations::miniapp::loopx_cli::LoopxCliProcessAdapter::new( + loopx_cli_config, + ); + let loopx_cli_adapter = match openbitfun_services_integrations::miniapp::loopx_github::GithubLoopxIntakeMetadataProvider::new() { + Ok(provider) => loopx_cli_adapter.with_intake_metadata_provider(Arc::new(provider)), + Err(error) => { + log::warn!("LoopX GitHub intake metadata is unavailable: {error}"); + loopx_cli_adapter + } + }; + let loopx_cli: Arc = + Arc::new(loopx_cli_adapter); + let loopx_workspace: Arc = + Arc::new( + openbitfun_services_integrations::miniapp::loopx_workspace::LoopxWorkspaceService::new( + openbitfun_services_integrations::miniapp::loopx_workspace::LoopxWorkspaceServiceConfig::new( + // Prefer a short home-based root: target repositories can + // contain paths near the Windows MAX_PATH limit, and a + // deep AppData prefix made worktree checkouts fail with + // "Filename too long" -> "Could not reset index file". + dirs::home_dir() + .map(|home| home.join(".bitfun").join("loopx-workspaces")) + .unwrap_or_else(|| { + path_manager + .miniapp_dir( + openbitfun_product_domains::miniapp::loopx::LOOPX_BUILTIN_APP_ID, + ) + .join("workspaces") + }), + std::path::PathBuf::from("git"), + ), + ), + ); + let loopx_agent: Arc = Arc::new( + openbitfun_core::miniapp::loopx::CoreLoopxAgentPort::new(coordinator.clone()), + ); + let loopx_controller = openbitfun_core::miniapp::loopx::LoopxController::load( + loopx_cli, + loopx_workspace, + loopx_agent, + openbitfun_core::miniapp::loopx::LoopxStateStore::new( + path_manager + .miniapp_dir(openbitfun_product_domains::miniapp::loopx::LOOPX_BUILTIN_APP_ID) + .join("loopx-controller-state.json"), + ), + ) + .await; + event_router.subscribe_internal( + "loopx_tasks".to_string(), + Arc::new(openbitfun_core::miniapp::loopx::LoopxEventSubscriber::new( + loopx_controller.clone(), + )), + ); + let loopx_controller_state = api::miniapp_loopx_api::LoopxControllerState { + controller: loopx_controller.clone(), + }; + let loopx_environment_controller = loopx_controller.clone(); + tokio::spawn(async move { + if let Err(error) = loopx_environment_controller.refresh_environment().await { + log::warn!("LoopX environment initialization failed: {error}"); + } + }); + let mut builder = tauri::Builder::default(); let frontend_protocol_manager = Arc::clone(&frontend_workbench); builder = builder.register_uri_scheme_protocol( @@ -876,6 +965,7 @@ pub async fn run() { .manage(desktop_runtime) .manage(coordinator_state) .manage(scheduler_state) + .manage(loopx_controller_state) .manage(path_manager) .manage(coordinator) .manage(scheduler) @@ -912,6 +1002,30 @@ pub async fn run() { .setup(move |app| { let setup_started = Instant::now(); startup_trace.record_phase("tauri_setup_start", "native_setup"); + let mut loopx_events = app + .state::() + .controller + .subscribe(); + let loopx_event_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + loop { + match loopx_events.recv().await { + Ok(event) => { + if let Err(error) = + loopx_event_handle.emit("miniapp://loopx-event", event) + { + log::warn!("Failed to emit LoopX task event: {error}"); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + log::warn!( + "LoopX task event subscriber lagged; clients will replay by cursor: skipped={skipped}" + ); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); #[cfg(target_os = "macos")] { app.on_menu_event(|app, event| { @@ -1986,6 +2100,13 @@ pub async fn run() { api::miniapp_agent_api::miniapp_agent_cancel, api::miniapp_agent_api::miniapp_agent_turn_text, api::miniapp_agent_api::miniapp_agent_cancel_stale_runs, + api::miniapp_loopx_api::miniapp_loopx_attach, + api::miniapp_loopx_api::miniapp_loopx_list_models, + api::miniapp_loopx_api::miniapp_loopx_resolve_intake, + api::miniapp_loopx_api::miniapp_loopx_create_task, + api::miniapp_loopx_api::miniapp_loopx_action, + api::miniapp_loopx_api::miniapp_loopx_events_since, + api::miniapp_loopx_api::miniapp_loopx_turn_output_since, api::miniapp_export_api::miniapp_render_slide_page, // Browser API (embedded webview) api::browser_api::browser_webview_eval, diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 2f7cbdd23a..ecad3600b6 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -357,6 +357,7 @@ tools-miniapp = [ "openbitfun-product-domains/appearance-market", "openbitfun-product-domains/miniapp", "openbitfun-services-integrations/miniapp-runtime", + "openbitfun-services-integrations/miniapp-loopx", "openbitfun-services-integrations/miniapp-market", "runtime-services", "dep:reqwest", diff --git a/src/crates/assembly/core/builtin_playbooks/im_send_message.yaml b/src/crates/assembly/core/builtin_playbooks/im_send_message.yaml index 6f0ab8f838..9e5ad035d6 100644 --- a/src/crates/assembly/core/builtin_playbooks/im_send_message.yaml +++ b/src/crates/assembly/core/builtin_playbooks/im_send_message.yaml @@ -32,14 +32,14 @@ parameters: - name: search_chord description: "Keyboard shortcut to focus the in-app search box. Default ['command','f'] (macOS)." required: false - default: ["command", "f"] + default: '["command", "f"]' - name: send_keys description: | Chord that submits the message in this app. Default ['return'] works for WeChat/iMessage/Telegram. Use ['command','return'] for Slack/Lark where Return inserts a newline. required: false - default: ["return"] + default: '["return"]' steps: - domain: system diff --git a/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md b/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md index 84070c1dbf..b579c990df 100644 --- a/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md +++ b/src/crates/assembly/core/builtin_skills/miniapp-dev/references/examples/demo-git-graph/README.md @@ -28,7 +28,7 @@ This demo showcases OpenBitFun MiniApp's full-stack collaboration capability — 1. **UI → Bridge**: `app.call('git.log', { cwd, maxCount })` etc. via `window.app` (JSON-RPC) 2. **Bridge → Tauri**: postMessage intercepted by the host `useMiniAppBridge`, which calls `miniapp_worker_call` 3. **Tauri → Worker**: Rust writes the request to Worker stdin (JSON-RPC) -4. **Worker**: `worker_host.js` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package +4. **Worker**: `worker_host.cjs` loads `source/worker.js`; exported handlers are invoked — primarily `git.graphData` (returns commits + refs + stashes + uncommitted in one response), plus `git.show`, `git.checkout`, `git.merge`, `git.push`, `git.stashPush`, and 20+ other methods — all backed by the `simple-git` npm package 5. **Worker → Tauri → Bridge → UI**: response travels back via stderr → Rust → postMessage to iframe → UI refreshes graph and detail panel ### Directory Structure @@ -117,7 +117,7 @@ miniapps/git-graph/ 1. **UI → Bridge**:`app.call('git.log', { cwd, maxCount })` 等通过 `window.app` 发起 RPC 2. **Bridge → Tauri**:postMessage 被宿主 `useMiniAppBridge` 接收,调用 `miniapp_worker_call` 3. **Tauri → Worker**:Rust 将请求写入 Worker 进程 stdin(JSON-RPC) -4. **Worker**:`worker_host.js` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 +4. **Worker**:`worker_host.cjs` 加载本目录 `source/worker.js`,其导出的处理函数被调用 — 主要是 `git.graphData`(一次返回提交 + refs + stash + 未提交变更),以及 `git.show`、`git.checkout`、`git.merge`、`git.push`、`git.stashPush` 等 20+ 个方法 — 均基于 `simple-git` npm 包 5. **Worker → Tauri → Bridge → UI**:响应经 stderr 回传 Rust,再 postMessage 回 iframe,UI 更新图谱与详情 ### 目录结构 diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index c11c3edb36..07ff3fa49e 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -4443,6 +4443,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet tool_call_id.clone(), TASK_TOOL_NAME, ), + params: None, result: data.clone(), result_for_assistant: Some(assistant_text.clone()), image_attachments: None, @@ -4465,6 +4466,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet tool_call_id.clone(), TASK_TOOL_NAME, ), + params: None, reason: error_text.clone(), duration_ms: Some(duration_ms), queue_wait_ms: None, @@ -4478,6 +4480,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet tool_call_id.clone(), TASK_TOOL_NAME, ), + params: None, error: error_text.clone(), duration_ms: Some(duration_ms), queue_wait_ms: None, @@ -6107,28 +6110,29 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .or(remote_ssh_host.as_deref()), ) .await?; - match self - .restore_session_from_storage_path(&restore_path, &session_id) - .await - { - Ok(_) => { - let restored_messages = self - .session_manager - .get_context_messages(&session_id) - .await?; - info!( - "Session history restored from persistence: session_id={}, messages: {} -> {}", - session_id, - context_messages.len(), - restored_messages.len() - ); - } - Err(e) => { - debug!( - "Failed to restore session history (may be new session): session_id={}, error={}", - session_id, e - ); - } + let persisted_metadata = self + .session_manager + .persistence_manager() + .load_session_metadata(&restore_path, &session_id) + .await?; + if persisted_metadata.is_none() { + debug!( + "Session history restore skipped for new session: session_id={}", + session_id + ); + } else { + self.restore_session_from_storage_path(&restore_path, &session_id) + .await?; + let restored_messages = self + .session_manager + .get_context_messages(&session_id) + .await?; + info!( + "Session history restored from persistence: session_id={}, messages: {} -> {}", + session_id, + context_messages.len(), + restored_messages.len() + ); } } diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index 2a55a2542d..2699d98856 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -1411,6 +1411,7 @@ impl RoundExecutor { tool_call.tool_id.clone(), tool_call.tool_name.clone(), ), + params: None, error: format!("Tool arguments stream interrupted: {}", error), duration_ms: None, queue_wait_ms: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs index 19e4aaec38..e7c76a5598 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs @@ -144,6 +144,11 @@ impl PlaybookTool { /// Try to parse a string as a native JSON type (number / bool), falling /// back to a JSON string. fn parse_typed_value(s: &str) -> Value { + if let Ok(value) = serde_json::from_str::(s) { + if value.is_array() || value.is_object() || value.is_null() { + return value; + } + } if let Ok(n) = s.parse::() { return json!(n); } diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs index 4f10f8907d..9b4f3d9cbd 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs @@ -189,6 +189,7 @@ impl ToolStateManager { confirmation_wait_ms, execution_ms, } => ToolStateEventKind::Completed { + params: Some(task.invocation.wire_arguments.clone()), result: result.content(), result_for_assistant: match result { crate::agentic::tools::framework::ToolResult::Result { @@ -219,6 +220,7 @@ impl ToolStateManager { confirmation_wait_ms, execution_ms, } => ToolStateEventKind::Failed { + params: Some(task.invocation.wire_arguments.clone()), error: error.clone(), duration_ms: *duration_ms, queue_wait_ms: *queue_wait_ms, @@ -235,6 +237,7 @@ impl ToolStateManager { confirmation_wait_ms, execution_ms, } => ToolStateEventKind::Cancelled { + params: Some(task.invocation.wire_arguments.clone()), reason: reason.clone(), duration_ms: *duration_ms, queue_wait_ms: *queue_wait_ms, diff --git a/src/crates/assembly/core/src/miniapp/js_worker_pool.rs b/src/crates/assembly/core/src/miniapp/js_worker_pool.rs index dd16e31589..126db01664 100644 --- a/src/crates/assembly/core/src/miniapp/js_worker_pool.rs +++ b/src/crates/assembly/core/src/miniapp/js_worker_pool.rs @@ -29,11 +29,13 @@ impl JsWorkerPool { pub fn new( path_manager: Arc, worker_host_path: PathBuf, + resource_dir: Option, ) -> OpenBitFunResult { let event_sink: SharedMiniAppWorkerEventSink = Arc::new(CoreMiniAppWorkerEventSink); ServiceJsWorkerPool::new( path_manager.miniapps_dir(), worker_host_path, + resource_dir, Some(event_sink), ) .map(|inner| Self { inner }) @@ -183,6 +185,7 @@ impl JsWorkerPool { inner: ServiceJsWorkerPool::from_runtime( path_manager.miniapps_dir(), worker_host_path, + None, runtime, Some(Arc::new(CoreMiniAppWorkerEventSink)), ), diff --git a/src/crates/assembly/core/src/miniapp/loopx/agent_adapter.rs b/src/crates/assembly/core/src/miniapp/loopx/agent_adapter.rs new file mode 100644 index 0000000000..c2189f5e6f --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/agent_adapter.rs @@ -0,0 +1,579 @@ +use super::tool_activity::project_tool_activity; +use crate::agentic::coordination::ConversationCoordinator; +use openbitfun_agent_runtime::permission::PERMISSION_MODE_CONTEXT_KEY; +use openbitfun_events::AgenticEvent; +use openbitfun_product_domains::miniapp::loopx::{ + required_permission_scopes_are_granted, LoopxAgentCancelRequest, LoopxAgentCancelResult, + LoopxAgentFinishRequest, LoopxAgentFinishResult, LoopxAgentOutputSinceRequest, + LoopxAgentOutputSinceResult, LoopxAgentPort, LoopxAgentProbeRequest, LoopxAgentProbeResult, + LoopxAgentResetRequest, LoopxAgentResetResult, LoopxAgentStartRequest, LoopxAgentStartResult, + LoopxHostFuture, LoopxHostPortError, LoopxHostPortErrorKind, LoopxTurnOutputEvent, + LoopxTurnOutputEventKind, LOOPX_BUILTIN_APP_ID, +}; +use openbitfun_runtime_ports::{ + AgentSessionCreateRequest, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionSource, + AgentTurnCancellationPort, AgentTurnCancellationRequest, PermissionMode, +}; +use std::path::Path; +use std::sync::Arc; + +const LOOPX_AGENT_TYPE: &str = "agentic"; +const LOOPX_AGENT_CAPABILITIES: &[&str] = &[ + "filesystem_read", + "filesystem_write", + "shell", + "network", + "external_evidence_poll", +]; + +pub struct CoreLoopxAgentPort { + coordinator: Arc, +} + +impl CoreLoopxAgentPort { + pub fn new(coordinator: Arc) -> Self { + Self { coordinator } + } +} + +impl LoopxAgentPort for CoreLoopxAgentPort { + fn available_capabilities(&self) -> Vec { + LOOPX_AGENT_CAPABILITIES + .iter() + .map(|capability| (*capability).to_string()) + .collect() + } + + fn probe(&self, request: LoopxAgentProbeRequest) -> LoopxHostFuture<'_, LoopxAgentProbeResult> { + Box::pin(async move { + let config_service = crate::service::config::get_global_config_service() + .await + .map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + format!("LoopX Agent model configuration is unavailable: {error}"), + &request.operation_id, + ) + })?; + let global_config: crate::service::config::types::GlobalConfig = + config_service.get_config(None).await.map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + format!("LoopX Agent model configuration could not be read: {error}"), + &request.operation_id, + ) + })?; + let requested = request.model_id.as_deref().unwrap_or("auto").trim(); + let selector = if requested.is_empty() || matches!(requested, "auto" | "primary") { + "primary" + } else { + requested + }; + let model_id = global_config + .ai + .resolve_model_selection(selector) + .ok_or_else(|| { + host_error( + LoopxHostPortErrorKind::NotFound, + format!("LoopX Agent model '{selector}' is not configured or enabled"), + &request.operation_id, + ) + })?; + let model = global_config + .ai + .models + .iter() + .find(|model| model.id == model_id && model.enabled) + .ok_or_else(|| { + host_error( + LoopxHostPortErrorKind::NotFound, + format!("LoopX Agent model '{model_id}' is unavailable"), + &request.operation_id, + ) + })?; + if !model.supports_text_generation() { + return Err(host_error( + LoopxHostPortErrorKind::Unsupported, + format!("LoopX Agent model '{model_id}' does not support text chat"), + &request.operation_id, + )); + } + let supports_images = model.supports_image_understanding(); + Ok(LoopxAgentProbeResult { + model_id, + supports_images, + }) + }) + } + + fn start(&self, request: LoopxAgentStartRequest) -> LoopxHostFuture<'_, LoopxAgentStartResult> { + Box::pin(async move { + if request.worktree_path.trim().is_empty() { + return Err(host_error( + LoopxHostPortErrorKind::InvalidInput, + "LoopX Agent worktree path is required", + &request.operation_id, + )); + } + if !required_permission_scopes_are_granted(&request.granted_scopes) { + return Err(host_error( + LoopxHostPortErrorKind::InvalidInput, + "LoopX Agent cannot run headlessly without every required intake permission scope", + &request.operation_id, + )); + } + let session_id = format!("loopx-{}", uuid::Uuid::new_v4()); + let turn_id = format!("loopx-turn-{}", uuid::Uuid::new_v4()); + let task_id = request.task_id.clone(); + let metadata = loopx_session_metadata(&request); + let created = AgentSubmissionPort::create_transient_session_with_id( + self.coordinator.as_ref(), + session_id.clone(), + AgentSessionCreateRequest { + session_name: format!("LoopX #{}", request.metadata.item.number), + agent_type: LOOPX_AGENT_TYPE.to_string(), + agent_route_key: None, + workspace_path: Some(request.worktree_path), + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + model_id: (!request.model_id.trim().is_empty() && request.model_id != "auto") + .then_some(request.model_id), + metadata: metadata.clone(), + }, + ) + .await + .map_err(|error| map_port_error(error, &request.operation_id))?; + log::info!( + "LoopX transient Agent session created: task_id={}, session_id={}, requested_turn_id={}", + task_id, + created.session_id, + turn_id + ); + let submitted = AgentSubmissionPort::submit_message( + self.coordinator.as_ref(), + AgentSubmissionRequest { + session_id: created.session_id.clone(), + message: request.instruction, + turn_id: Some(turn_id.clone()), + source: Some(AgentSubmissionSource::DesktopApi), + attachments: Vec::new(), + metadata, + }, + ) + .await + .map_err(|error| map_port_error(error, &request.operation_id))?; + if !submitted.accepted { + let _ = self + .coordinator + .discard_transient_session( + Path::new(&created.workspace_path.unwrap_or_default()), + None, + None, + &created.session_id, + ) + .await; + return Err(host_error( + LoopxHostPortErrorKind::Conflict, + "LoopX Agent turn was not accepted", + &request.operation_id, + )); + } + log::info!( + "LoopX Agent turn accepted: task_id={}, session_id={}, turn_id={}", + task_id, + created.session_id, + submitted.turn_id + ); + Ok(LoopxAgentStartResult { + session_id: created.session_id, + turn_id: submitted.turn_id, + }) + }) + } + + fn cancel( + &self, + request: LoopxAgentCancelRequest, + ) -> LoopxHostFuture<'_, LoopxAgentCancelResult> { + Box::pin(async move { + let result = AgentTurnCancellationPort::cancel_turn( + self.coordinator.as_ref(), + AgentTurnCancellationRequest { + session_id: request.session_id, + turn_id: Some(request.turn_id), + source: Some(AgentSubmissionSource::DesktopApi), + requester_session_id: None, + reason: Some("LoopX task paused by the user".to_string()), + wait_timeout_ms: Some(5_000), + cancel_descendants: true, + }, + ) + .await + .map_err(|error| map_port_error(error, &request.operation_id))?; + Ok(LoopxAgentCancelResult { + target_operation_id: request.target_operation_id, + cancelled: result.requested, + }) + }) + } + + fn finish( + &self, + request: LoopxAgentFinishRequest, + ) -> LoopxHostFuture<'_, LoopxAgentFinishResult> { + Box::pin(async move { + let discarded = self + .coordinator + .discard_transient_session( + Path::new(&request.worktree_path), + None, + None, + &request.session_id, + ) + .await + .map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + error.to_string(), + &request.operation_id, + ) + })?; + log::info!( + "LoopX transient Agent session discarded: task_id={}, session_id={}, discarded={}", + request.task_id, + request.session_id, + discarded + ); + Ok(LoopxAgentFinishResult { + session_id: request.session_id, + discarded, + }) + }) + } + + fn reset(&self, request: LoopxAgentResetRequest) -> LoopxHostFuture<'_, LoopxAgentResetResult> { + Box::pin(async move { + let path_manager = + crate::infrastructure::try_get_path_manager_arc().map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + error.to_string(), + &request.operation_id, + ) + })?; + let root = crate::service::session_projection_store::runtime_event_log_dir( + path_manager.as_ref(), + ); + let mut entries = match tokio::fs::read_dir(&root).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(LoopxAgentResetResult::default()) + } + Err(error) => { + return Err(host_error( + LoopxHostPortErrorKind::Io, + format!("Failed to read LoopX runtime event directory: {error}"), + &request.operation_id, + )) + } + }; + let mut removed = 0_u32; + while let Some(entry) = entries.next_entry().await.map_err(|error| { + host_error( + LoopxHostPortErrorKind::Io, + format!("Failed to enumerate LoopX runtime event logs: {error}"), + &request.operation_id, + ) + })? { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name.starts_with("loopx-") || !name.ends_with(".jsonl") { + continue; + } + if !entry + .file_type() + .await + .map_err(|error| { + host_error( + LoopxHostPortErrorKind::Io, + format!("Failed to inspect LoopX runtime event log: {error}"), + &request.operation_id, + ) + })? + .is_file() + { + continue; + } + tokio::fs::remove_file(entry.path()) + .await + .map_err(|error| { + host_error( + LoopxHostPortErrorKind::Io, + format!("Failed to remove LoopX runtime event log: {error}"), + &request.operation_id, + ) + })?; + removed = removed.saturating_add(1); + } + Ok(LoopxAgentResetResult { + removed_runtime_event_logs: removed, + }) + }) + } + + fn output_since( + &self, + request: LoopxAgentOutputSinceRequest, + ) -> LoopxHostFuture<'_, LoopxAgentOutputSinceResult> { + Box::pin(async move { + let path_manager = + crate::infrastructure::try_get_path_manager_arc().map_err(|error| { + host_error( + LoopxHostPortErrorKind::Backend, + error.to_string(), + &request.operation_id, + ) + })?; + let root = crate::service::session_projection_store::runtime_event_log_dir( + path_manager.as_ref(), + ); + let page = crate::service::session_projection_store::read_runtime_events_since( + &root, + &request.session_id, + request.stream_id.as_deref(), + request.after_cursor, + request.limit, + ) + .map_err(|error| { + host_error(LoopxHostPortErrorKind::Io, error, &request.operation_id) + })?; + let Some(page) = page else { + return Ok(LoopxAgentOutputSinceResult { + next_cursor: request.after_cursor, + ..LoopxAgentOutputSinceResult::default() + }); + }; + let events = page + .events + .into_iter() + .filter_map(|record| { + turn_output_event(record.cursor, &request.turn_id, record.event) + }) + .collect(); + Ok(LoopxAgentOutputSinceResult { + stream_id: Some(page.stream_id), + events, + next_cursor: page.next_cursor, + has_more: page.has_more, + }) + }) + } +} + +fn turn_output_event( + cursor: u64, + expected_turn_id: &str, + event: AgenticEvent, +) -> Option { + if event.turn_id() != Some(expected_turn_id) { + return None; + } + match event { + AgenticEvent::TextChunk { + turn_id, + round_id, + text, + .. + } => Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::Text, + text: Some(text), + ..LoopxTurnOutputEvent::default() + }), + AgenticEvent::ThinkingChunk { + turn_id, + round_id, + content, + is_end, + .. + } => Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::Thinking, + text: Some(content), + is_end, + ..LoopxTurnOutputEvent::default() + }), + AgenticEvent::ModelRoundStarted { + turn_id, + round_id, + effective_model_name, + .. + } => Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::ModelRoundStarted, + text: Some(format!("Model round started: {effective_model_name}")), + ..LoopxTurnOutputEvent::default() + }), + AgenticEvent::ModelRoundCompleted { + turn_id, + round_id, + duration_ms, + .. + } => Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::ModelRoundCompleted, + text: Some(match duration_ms { + Some(value) => format!("Model round completed in {value} ms"), + None => "Model round completed".to_string(), + }), + ..LoopxTurnOutputEvent::default() + }), + AgenticEvent::ToolEvent { + turn_id, + round_id, + tool_event, + .. + } => { + let activity = project_tool_activity(&tool_event)?; + let text = activity + .details + .get("summary") + .cloned() + .unwrap_or(activity.message); + Some(LoopxTurnOutputEvent { + cursor, + turn_id, + round_id: Some(round_id), + kind: LoopxTurnOutputEventKind::Tool, + text: Some(text), + tool_name: Some(activity.tool_name), + tool_state: Some(activity.state.to_string()), + ..LoopxTurnOutputEvent::default() + }) + } + _ => None, + } +} + +fn loopx_session_metadata( + request: &LoopxAgentStartRequest, +) -> serde_json::Map { + serde_json::Map::from_iter([ + ("surface".to_string(), serde_json::json!("miniapp_agent")), + ("appId".to_string(), serde_json::json!(LOOPX_BUILTIN_APP_ID)), + ( + "loopxTaskId".to_string(), + serde_json::json!(request.task_id.clone()), + ), + ( + "generation".to_string(), + serde_json::json!(request.generation), + ), + ( + "goalId".to_string(), + serde_json::json!(request.metadata.goal_id.clone()), + ), + ( + "loopxTurnId".to_string(), + serde_json::json!(request.metadata.loopx_turn_id.clone()), + ), + ( + PERMISSION_MODE_CONTEXT_KEY.to_string(), + serde_json::json!(PermissionMode::AutoApprove.as_str()), + ), + ]) +} + +fn map_port_error( + error: openbitfun_runtime_ports::PortError, + operation_id: &str, +) -> LoopxHostPortError { + host_error( + LoopxHostPortErrorKind::Backend, + error.to_string(), + operation_id, + ) +} + +fn host_error( + kind: LoopxHostPortErrorKind, + message: impl Into, + operation_id: &str, +) -> LoopxHostPortError { + LoopxHostPortError { + kind, + message: message.into(), + operation_id: Some(operation_id.to_string()), + retryable: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openbitfun_events::{ToolEventData, ToolEventIdentity}; + use openbitfun_product_domains::miniapp::loopx::{ + LoopxAgentTurnMetadata, LoopxIssueKey, LoopxItemKind, LoopxRepositoryKey, + LOOPX_REQUIRED_PERMISSION_SCOPES, + }; + + #[test] + fn loopx_sessions_use_the_transient_miniapp_surface_contract() { + let request = LoopxAgentStartRequest { + task_id: "task-1".to_string(), + generation: 2, + granted_scopes: LOOPX_REQUIRED_PERMISSION_SCOPES.to_vec(), + metadata: LoopxAgentTurnMetadata { + goal_id: "goal-1".to_string(), + loopx_turn_id: "turn-1".to_string(), + item: LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }, + attempt: 1, + }, + ..LoopxAgentStartRequest::default() + }; + + let metadata = loopx_session_metadata(&request); + assert_eq!(metadata["surface"], serde_json::json!("miniapp_agent")); + assert_eq!(metadata["appId"], serde_json::json!(LOOPX_BUILTIN_APP_ID)); + assert_eq!( + metadata[PERMISSION_MODE_CONTEXT_KEY], + serde_json::json!(PermissionMode::AutoApprove.as_str()) + ); + } + + #[test] + fn live_output_omits_partial_tool_parameters() { + let event = AgenticEvent::ToolEvent { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::ParamsPartial { + identity: ToolEventIdentity::direct("tool-1", "ExecCommand"), + params: "{\"cmd\":\"cargo".to_string(), + }, + }; + + assert!(turn_output_event(1, "turn-1", event).is_none()); + } +} diff --git a/src/crates/assembly/core/src/miniapp/loopx/controller.rs b/src/crates/assembly/core/src/miniapp/loopx/controller.rs new file mode 100644 index 0000000000..1f3e8c2ed3 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/controller.rs @@ -0,0 +1,4606 @@ +use super::tool_activity::ToolActivityProjection; +use super::{LoopxPersistedState, LoopxStateStore, LoopxTaskRuntimeRecord}; +use crate::util::elapsed_ms_u64; +use openbitfun_product_domains::miniapp::loopx::*; +use std::collections::{BTreeMap, HashMap}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::sync::{broadcast, mpsc, Mutex, RwLock}; + +const DEFAULT_AGENT_ID: &str = "bitfun-agent"; +const EVENT_CHANNEL_CAPACITY: usize = 256; +const INTAKE_PREVIEW_TTL_MS: i64 = 5 * 60 * 1000; +const MAX_INTAKE_PREVIEWS: usize = 64; +const MAX_AGENT_SUMMARY_CHARS: usize = 16_000; +const GOAL_RECONCILE_TTL_MS: i64 = 30_000; +const GOAL_RECONCILE_DEADLINE_MS: i64 = 30_000; +/// LoopX 0.5.1 exposes only cadence labels for the outer-controller profile, +/// so the host supplies the concrete wait interval for waiting goals. +const WAIT_RESCHEDULE_FALLBACK_MS: u64 = 60_000; +/// Minimum spacing between consecutive monitor-class re-check turns for one +/// goal. The pinned LoopX v0.5.1 envelope carries no numeric monitor_wait +/// cadence, so a freshly created successor tracking todo is immediately +/// `RunNow`; without this floor the host would drive back-to-back re-check +/// turns that re-verify an external state that cannot have changed. The +/// anchor is the goal's last durable settlement time, not a new host counter. +/// Matches the documented upstream monitor_wait host floor (15 minutes); +/// numeric scheduler hints from a newer pin still take priority through the +/// `Wait` branch's `scheduler_hint_ms` path. +const MONITOR_COMPAT_INTERVAL_MS: u64 = 15 * 60 * 1000; +/// Backoff before re-driving after a retryable turn-build conflict. +const TURN_CONFLICT_RETRY_MS: u64 = 5_000; + +/// One-shot host note appended to the corrective turn instruction after a +/// NoDurableProgress settlement. The note routes the agent through the LoopX +/// CLI write boundary so settlement can validate the writeback; it never +/// fabricates goal state on the agent's behalf. +const LOOPX_DURABLE_COMPENSATION_NOTE: &str = "The previous turn finished, but LoopX settlement reported no validated durable progress. Re-submit the pending vision and resolution artifacts through the LoopX CLI write boundary (`loopx refresh-state`) so they are recorded inside the goal workspace; do not write these artifacts to paths outside the workspace such as the system temp directory. If the previous turn modified product source files under the worktree but did not commit them, commit those product changes to the task branch with a descriptive message before ending the turn (leave `.loopx`/`.codex` bookkeeping out of the commit). When the writeback receipts are confirmed, end the turn so settlement can validate them."; + +/// `recovery_reason` for a Goal that is still Active after its plan ran dry: +/// no open todo, no waiting user decision, and no selected action remain, so +/// the host contract forbids fabricating a terminal transition. The task +/// parks with this explicit reason (instead of a generic execution failure) +/// so the recovery card can explain the plan is exhausted and guide the +/// owner: resume once the Goal gains a new todo or gate, or finish the +/// delivery manually from the task branch. +const LOOPX_PLAN_EXHAUSTED_REASON: &str = "plan_exhausted"; + +/// Owner-facing message persisted on the task when the RunNow frontier is +/// exhausted. Kept in English because the same text is host telemetry; the +/// MiniApp renders localized guidance keyed off `recovery_reason`. +const LOOPX_PLAN_EXHAUSTED_MESSAGE: &str = "LoopX goal is still active but its plan is exhausted: no open todo, no waiting user decision, and no selected action remain. BitFun does not fabricate a terminal Goal transition, so the task parks for an owner decision; the worktree, evidence, and any commits are preserved. Resume after the goal gains a new todo or gate (for example after an upstream PR merge), or finish the delivery manually from the task branch."; + +struct ScheduledTask { + task_id: String, +} + +struct InProgressGuard<'a>(&'a AtomicBool); + +impl Drop for InProgressGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +#[derive(Default)] +struct BufferedProgress(StdMutex>); + +impl BufferedProgress { + fn take(&self) -> Vec { + std::mem::take( + &mut *self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + ) + } +} + +impl LoopxCliProgressSink for BufferedProgress { + fn report(&self, progress: LoopxCliProgress) { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(progress); + } +} + +pub struct LoopxController { + cli: Arc, + workspace: Arc, + agent: Arc, + agent_capabilities: Vec, + store: LoopxStateStore, + state: RwLock, + mutation_lock: Mutex<()>, + reconcile_lock: Mutex<()>, + previews: RwLock>, + active_tasks: Mutex>, + active_repositories: Mutex>, + event_sender: broadcast::Sender, + task_sender: mpsc::UnboundedSender, + load_error: RwLock>, + install_in_progress: AtomicBool, + reset_in_progress: AtomicBool, +} + +impl LoopxController { + pub async fn load( + cli: Arc, + workspace: Arc, + agent: Arc, + store: LoopxStateStore, + ) -> Arc { + let now = now_ms(); + let (mut persisted, load_error) = match store.load().await { + Ok(Some(state)) => (state, None), + Ok(None) => (LoopxPersistedState::new(now), None), + Err(error) => (LoopxPersistedState::new(now), Some(error)), + }; + let restart_changed = load_error.is_none() && persisted.apply_restart_policy(now); + let (event_sender, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); + let (task_sender, mut task_receiver) = mpsc::unbounded_channel::(); + let agent_capabilities = agent.available_capabilities(); + let controller = Arc::new(Self { + cli, + workspace, + agent, + agent_capabilities, + store, + state: RwLock::new(persisted), + mutation_lock: Mutex::new(()), + reconcile_lock: Mutex::new(()), + previews: RwLock::new(HashMap::new()), + active_tasks: Mutex::new(HashMap::new()), + active_repositories: Mutex::new(HashMap::new()), + event_sender, + task_sender, + load_error: RwLock::new(load_error), + install_in_progress: AtomicBool::new(false), + reset_in_progress: AtomicBool::new(false), + }); + if restart_changed { + if let Err(error) = controller.persist_current().await { + *controller.load_error.write().await = Some(error); + } + } + let task_runner = Arc::clone(&controller); + tokio::spawn(async move { + while let Some(scheduled) = task_receiver.recv().await { + let task_runner = Arc::clone(&task_runner); + tokio::spawn(async move { + if !task_runner.reserve_scheduled_task(&scheduled.task_id).await { + return; + } + loop { + let result = task_runner.drive_task(scheduled.task_id.clone()).await; + if let Err(error) = result { + let _ = task_runner.fail_task(&scheduled.task_id, error).await; + } + if !task_runner.release_scheduled_task(&scheduled.task_id).await { + break; + } + if !task_runner.reserve_scheduled_task(&scheduled.task_id).await { + break; + } + } + }); + } + }); + controller.enqueue_ready_tasks_after_load().await; + controller + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.event_sender.subscribe() + } + + pub async fn attach( + &self, + execution_domain: LoopxExecutionDomain, + execution_support: LoopxExecutionSupport, + unsupported_reason: Option, + ) -> LoopxAttachResponse { + let environment_ready = + self.state.read().await.environment.status == LoopxEnvironmentStatus::Ready; + if execution_support == LoopxExecutionSupport::Supported + && environment_ready + && self.load_error.read().await.is_none() + { + self.reconcile_goal_projections(false).await; + } + let state = self.state.read().await; + let mut snapshot = state.snapshot( + execution_domain, + execution_support, + unsupported_reason, + now_ms(), + ); + if let Some(error) = self.load_error.read().await.clone() { + snapshot.execution_support = LoopxExecutionSupport::UnsupportedExecutionDomain; + snapshot.unsupported_reason = Some(error); + snapshot.environment.status = LoopxEnvironmentStatus::Blocked; + } + LoopxAttachResponse { snapshot } + } + + /// Re-hydrates LoopX-owned projections after the trusted Desktop surface + /// observes a suspend/resume clock discontinuity. Active Agent turns are + /// preserved: Windows can resume their subprocess tree successfully, so + /// this path invalidates stale clients and refreshes only read-only host + /// facts instead of manufacturing a failure or duplicate turn. + pub async fn handle_host_resume(self: &Arc) -> Result<(), String> { + if self.reset_in_progress.load(Ordering::Acquire) { + return Ok(()); + } + { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let start_cursor = state.cursor; + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::SnapshotInvalidated, + level: LoopxEventLevel::Info, + source: LoopxEventSource::Controller, + message: "Host resumed; refreshing LoopX projections".to_string(), + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + self.broadcast_new_events(&persisted, start_cursor); + } + + if let Err(error) = self.refresh_environment().await { + log::warn!("LoopX environment refresh after host resume failed: {error}"); + } + self.reconcile_goal_projections(true).await; + Ok(()) + } + + /// Refresh the read-only LoopX Goal projection before presenting persisted + /// host jobs. Failures preserve the last local projection and are surfaced + /// in logs; they never manufacture a Goal transition or local fallback. + async fn reconcile_goal_projections(&self, force: bool) { + let Ok(_reconcile) = self.reconcile_lock.try_lock() else { + return; + }; + let now = now_ms(); + let candidates = { + let state = self.state.read().await; + state + .tasks + .iter() + .filter(|task| { + task.goal_id.as_deref().is_some_and(|id| !id.is_empty()) + && task + .workspace_path + .as_deref() + .is_some_and(|path| !path.is_empty()) + && !matches!( + task.state, + LoopxTaskState::Preparing + | LoopxTaskState::Running + | LoopxTaskState::Cancelling + | LoopxTaskState::Aborted + | LoopxTaskState::Archived + ) + // Passive states change through explicit host actions. Re- + // inspecting them on every UI attach only spawns sidecar + // processes that can time out. Suspend/resume keeps the force + // path so an externally changed Goal is still repaired. + && (force + || (task.state == LoopxTaskState::WaitingForUser + && task.pending_gate_id.is_none()) + || !matches!( + task.state, + LoopxTaskState::WaitingForUser + | LoopxTaskState::Completed + | LoopxTaskState::Stopped + | LoopxTaskState::Failed + | LoopxTaskState::RecoveryRequired + )) + && (force + || task.goal_state.is_none() + || now.saturating_sub(task.updated_at) >= GOAL_RECONCILE_TTL_MS) + }) + .filter_map(|task| { + let runtime = state.runtime.get(&task.task_id)?.clone(); + // The reconcile throttle is tracked per task on the runtime + // record, not via `updated_at`: progress events from the + // reconcile itself must not restart the window, otherwise + // every UI attach spawns a fresh sidecar probe. + let throttle_ok = force + || task.goal_state.is_none() + || runtime + .last_goal_reconcile_at_ms + .map(|at| now.saturating_sub(at) >= GOAL_RECONCILE_TTL_MS) + .unwrap_or(true); + (throttle_ok && !runtime.registry_path.is_empty()) + .then(|| (task.clone(), runtime)) + }) + .collect::>() + }; + + for (task, runtime) in candidates { + let mut context = self.goal_context(&task, &runtime); + context.call.operation_id = + format!("reconcile-goal-{}-{}", task.task_id, uuid::Uuid::new_v4()); + context.call.deadline_at = Some(now_ms().saturating_add(GOAL_RECONCILE_DEADLINE_MS)); + let progress = BufferedProgress::default(); + let result = self + .cli + .inspect_goal( + LoopxCliInspectGoalRequest { + context, + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + }, + &progress, + ) + .await; + if let Err(error) = self.record_progress(progress.take()).await { + log::warn!( + "Failed to persist LoopX reconciliation progress: task_id={}, error={}", + task.task_id, + error + ); + } + let snapshot = match result { + Ok(snapshot) => snapshot, + Err(error) => { + log::warn!( + "LoopX Goal reconciliation failed: task_id={}, goal_id={}, error={}", + task.task_id, + task.goal_id.as_deref().unwrap_or("unknown"), + error + ); + continue; + } + }; + // Record the attempt regardless of outcome so a chatty UI attach + // cadence cannot turn reconciliation into a sidecar hot loop. + // Bookkeeping write: this deliberately does not bump the state + // revision — background reconciliation must never invalidate the + // expected revision of a pending UI action (for example the + // repository recovery button). + let reconciled_at = now_ms(); + { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + if let Some(runtime) = state.runtime.get_mut(&task.task_id) { + runtime.last_goal_reconcile_at_ms = Some(reconciled_at); + } + let persisted = state.clone(); + drop(state); + if let Err(error) = self.store.save(&persisted).await { + log::warn!( + "Failed to persist LoopX reconcile throttle: task_id={}, error={}", + task.task_id, + error + ); + } + } + if let Err(error) = self.apply_goal_projection(&task, &snapshot).await { + log::warn!( + "Failed to apply LoopX Goal projection: task_id={}, goal_id={}, error={}", + task.task_id, + snapshot.goal_id, + error + ); + } + } + } + + pub async fn events_since(&self, request: LoopxEventsSinceRequest) -> LoopxEventsSinceResponse { + self.state.read().await.events_since( + &request.stream_id, + request.after_cursor, + request.limit, + ) + } + + pub async fn turn_output_since( + &self, + request: LoopxTurnOutputSinceRequest, + ) -> LoopxTurnOutputSinceResponse { + let (task, runtime) = { + let state = self.state.read().await; + let Some(task) = state + .tasks + .iter() + .find(|task| task.task_id == request.task_id) + else { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::TaskNotFound, + task_id: request.task_id, + message: Some("LoopX task was not found".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + }; + let runtime = state + .runtime + .get(&task.task_id) + .cloned() + .unwrap_or_default(); + (task.clone(), runtime) + }; + + if task.state != LoopxTaskState::Running || task.phase != LoopxPhase::AgentRunning { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::NotRunning, + task_id: task.task_id, + turn_id: task.current_turn_id, + message: Some("LoopX task does not have an active Agent turn".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + } + let Some(session_id) = runtime.session_id else { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::OutputUnavailable, + task_id: task.task_id, + turn_id: task.current_turn_id, + message: Some("LoopX Agent session output is unavailable".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + }; + let Some(turn_id) = runtime + .agent_turn_id + .clone() + .or_else(|| task.current_turn_id.clone()) + else { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::OutputUnavailable, + task_id: task.task_id, + message: Some("LoopX Agent turn output is unavailable".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + }; + if request + .turn_id + .as_deref() + .is_some_and(|requested| requested != turn_id) + { + return LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::StaleTurn, + task_id: task.task_id, + turn_id: Some(turn_id), + message: Some("LoopX task moved to a different Agent turn".to_string()), + ..LoopxTurnOutputSinceResponse::default() + }; + } + + match self + .agent + .output_since(LoopxAgentOutputSinceRequest { + operation_id: format!("output-agent-{}", uuid::Uuid::new_v4()), + session_id, + turn_id: turn_id.clone(), + stream_id: request.stream_id, + after_cursor: request.after_cursor, + limit: request.limit, + }) + .await + { + Ok(page) => LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::Current, + task_id: task.task_id, + turn_id: Some(turn_id), + stream_id: page.stream_id, + events: page.events, + next_cursor: page.next_cursor, + has_more: page.has_more, + message: None, + }, + Err(error) => LoopxTurnOutputSinceResponse { + status: LoopxTurnOutputStatus::OutputUnavailable, + task_id: task.task_id, + turn_id: Some(turn_id), + message: Some(error.to_string()), + ..LoopxTurnOutputSinceResponse::default() + }, + } + } + + pub async fn refresh_environment(self: &Arc) -> Result<(), String> { + self.ensure_writable().await?; + let probe_id = uuid::Uuid::new_v4(); + self.mark_environment_checking().await?; + let progress = BufferedProgress::default(); + let handshake = self.cli.handshake( + LoopxCliHandshakeRequest { + call: LoopxCliCallContext { + operation_id: format!("environment-sidecar-{probe_id}"), + deadline_at: None, + }, + ..LoopxCliHandshakeRequest::default() + }, + &progress, + ); + let workspace = self.workspace.probe(LoopxWorkspaceProbeRequest { + operation_id: format!("environment-workspace-{probe_id}"), + repository: None, + }); + let agent = self.agent.probe(LoopxAgentProbeRequest { + operation_id: format!("environment-agent-{probe_id}"), + model_id: Some("auto".to_string()), + }); + let github_auth = self.probe_github_auth(); + let (handshake, workspace, agent, github_auth) = + tokio::join!(handshake, workspace, agent, github_auth); + self.record_progress(progress.take()).await?; + self.commit_environment(handshake, workspace, agent, github_auth) + .await?; + self.reconcile_goal_projections(true).await; + Ok(()) + } + + async fn probe_github_auth(&self) -> LoopxGithubAuthProbe { + let operation_id = format!("github-auth-{}", uuid::Uuid::new_v4()); + match self + .cli + .probe_github_auth(LoopxGithubAuthProbeRequest { + call: LoopxCliCallContext { + operation_id, + deadline_at: None, + }, + }) + .await + { + Ok(probe) => probe, + Err(error) => LoopxGithubAuthProbe { + authenticated: false, + detail: Some(format!("GitHub auth probe failed: {error}")), + ..LoopxGithubAuthProbe::default() + }, + } + } + + pub async fn resolve_intake( + &self, + request: LoopxResolveIntakeRequest, + ) -> Result { + self.ensure_writable().await?; + let target = parse_loopx_intake(&request.input).map_err(|error| error.to_string())?; + let probe_id = uuid::Uuid::new_v4(); + let repository = target.repository().clone(); + let model_id = request.model_id; + let progress = BufferedProgress::default(); + let resolved = self.cli.resolve_intake( + LoopxCliResolveIntakeRequest { + call: LoopxCliCallContext { + operation_id: format!("resolve-metadata-{probe_id}"), + deadline_at: None, + }, + input: request.input, + target: target.clone(), + }, + &progress, + ); + let workspace_probe = self.workspace.probe(LoopxWorkspaceProbeRequest { + operation_id: format!("resolve-workspace-{probe_id}"), + repository: Some(repository), + }); + let agent_probe = self.agent.probe(LoopxAgentProbeRequest { + operation_id: format!("resolve-agent-{probe_id}"), + model_id: Some(model_id.clone()), + }); + let (resolved, workspace_probe, agent_probe) = + tokio::join!(resolved, workspace_probe, agent_probe); + self.record_progress(progress.take()).await?; + let resolved = resolved.map_err(|error| error.to_string())?; + let scopes = LOOPX_REQUIRED_PERMISSION_SCOPES.to_vec(); + let model = match agent_probe { + Ok(probe) => LoopxModelCapability { + model_id, + available: true, + supports_images: probe.supports_images, + detail: Some(format!("Resolved Agent model: {}", probe.model_id)), + }, + Err(error) => LoopxModelCapability { + model_id, + available: false, + supports_images: false, + detail: Some(error.to_string()), + }, + }; + let workspace = match workspace_probe { + Ok(probe) => LoopxWorkspacePreview { + disposition: LoopxWorkspaceDisposition::CloneRequired, + path: None, + repository_verified: probe.repository_verified, + detail: Some(format!( + "{}; repository access verified", + probe + .git_version + .unwrap_or_else(|| "Git available".to_string()) + )), + }, + Err(error) => LoopxWorkspacePreview { + disposition: LoopxWorkspaceDisposition::Unavailable, + path: None, + repository_verified: false, + detail: Some(error.to_string()), + }, + }; + let fingerprint = build_intake_fingerprint( + &resolved.target, + &resolved.candidates, + None, + &model.model_id, + &scopes, + ); + let preview_resolved_at = if resolved.resolved_at > 0 { + resolved.resolved_at + } else { + now_ms() + }; + let expires_at = preview_resolved_at.saturating_add(INTAKE_PREVIEW_TTL_MS); + let preview = LoopxIntakePreview { + fingerprint: fingerprint.clone(), + target: resolved.target, + repository: resolved.repository, + workspace, + candidates: resolved.candidates, + truncated: resolved.truncated, + model, + permission_scopes: scopes, + resolved_at: preview_resolved_at, + expires_at: Some(expires_at), + }; + let mut previews = self.previews.write().await; + prune_intake_previews(&mut previews, now_ms()); + previews.insert(fingerprint, preview.clone()); + prune_intake_previews(&mut previews, now_ms()); + Ok(LoopxResolveIntakeResponse { preview }) + } + + pub async fn create_tasks( + self: &Arc, + request: LoopxCreateTaskRequest, + ) -> Result { + self.ensure_writable().await?; + if request.client_request_id.trim().is_empty() { + return Err("clientRequestId is required".to_string()); + } + let selected = request + .selected_items + .iter() + .cloned() + .collect::>(); + if selected.is_empty() { + return Err("Select at least one issue or pull request".to_string()); + } + { + let state = self.state.read().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxCreateTaskResponse { + outcomes: existing_outcomes(&state, &selected), + snapshot_revision: state.revision, + }); + } + } + let preview = match { + let mut previews = self.previews.write().await; + prune_intake_previews(&mut previews, now_ms()); + previews.get(&request.preview_fingerprint).cloned() + } { + Some(preview) => preview, + None => { + let state = self.state.read().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxCreateTaskResponse { + outcomes: existing_outcomes(&state, &selected), + snapshot_revision: state.revision, + }); + } + return Err("Intake preview is missing or stale; resolve it again".to_string()); + } + }; + if selected.iter().any(|key| { + !preview + .candidates + .iter() + .any(|candidate| &candidate.key == key) + }) { + return Err("Selected item was not present in the intake preview".to_string()); + } + if preview.workspace.disposition == LoopxWorkspaceDisposition::Unavailable + || !preview.workspace.repository_verified + { + return Err(preview.workspace.detail.clone().unwrap_or_else(|| { + "The repository workspace did not pass live Git verification".to_string() + })); + } + if !preview.model.available { + return Err(preview + .model + .detail + .clone() + .unwrap_or_else(|| "The selected Agent model is unavailable".to_string())); + } + if request.granted_scopes.iter().any(|scope| { + !preview.permission_scopes.contains(scope) || !intake_scope_is_pregrantable(*scope) + }) { + return Err("Intake includes a permission scope that was not previewed".to_string()); + } + if !required_permission_scopes_are_granted(&request.granted_scopes) { + return Err( + "All LoopX permission scopes shown in intake are required for an autonomous issue-fix task" + .to_string(), + ); + } + + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let start_cursor = state.cursor; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxCreateTaskResponse { + outcomes: existing_outcomes(&state, &selected), + snapshot_revision: state.revision, + }); + } + let existing = state + .tasks + .iter() + .map(|task| LoopxExistingTask { + task_id: task.task_id.clone(), + identity: task.identity.clone(), + state: task.state, + }) + .collect::>(); + let batch_id = (selected.len() > 1).then(|| uuid::Uuid::new_v4().to_string()); + let now = now_ms(); + let mut outcomes = Vec::new(); + let mut created_task_ids = Vec::new(); + for key in selected { + let candidate = preview + .candidates + .iter() + .find(|candidate| candidate.key == key) + .expect("selected candidates were validated before mutation"); + match decide_task_dedup(&key, candidate.state, &existing, request.retry_terminal) { + LoopxDedupDecision::OpenExisting { task_id } => { + outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::OpenedExisting, + task_id: Some(task_id), + ..LoopxCreateTaskOutcome::default() + }) + } + LoopxDedupDecision::RequireExplicitRetry { + previous_task_id, + next_attempt, + } => outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::RetryConfirmationRequired, + task_id: Some(previous_task_id), + attempt: Some(next_attempt), + ..LoopxCreateTaskOutcome::default() + }), + LoopxDedupDecision::ClosedNoop => outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::ClosedNoop, + ..LoopxCreateTaskOutcome::default() + }), + LoopxDedupDecision::NeedsLiveVerification => { + outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::NeedsLiveVerification, + ..LoopxCreateTaskOutcome::default() + }) + } + LoopxDedupDecision::CreateAttempt { attempt } => { + let task_id = uuid::Uuid::new_v4().to_string(); + let operation_id = format!("prepare-{task_id}-1"); + let task = LoopxTaskSnapshot { + task_id: task_id.clone(), + batch_id: batch_id.clone(), + identity: LoopxTaskIdentity { + item: key.clone(), + attempt, + title: candidate.title.clone(), + description: candidate.description.clone(), + state: candidate.state, + labels: candidate.labels.clone(), + }, + generation: 1, + revision: 1, + agent_id: Some(DEFAULT_AGENT_ID.to_string()), + state: LoopxTaskState::Preparing, + phase: LoopxPhase::PreparingWorkspace, + model_id: Some(request.model_id.clone()), + granted_scopes: request.granted_scopes.clone(), + created_at: now, + updated_at: now, + ..LoopxTaskSnapshot::default() + }; + state.runtime.insert( + task_id.clone(), + LoopxTaskRuntimeRecord { + operation_id, + ..LoopxTaskRuntimeRecord::default() + }, + ); + state.tasks.push(task); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + task_id: Some(task_id.clone()), + generation: Some(1), + revision: Some(1), + kind: LoopxEventKind::TaskCreated, + source: LoopxEventSource::Controller, + phase: Some(LoopxPhase::PreparingWorkspace), + message: "LoopX task reserved before workspace preparation".to_string(), + important: true, + occurred_at: now, + ..LoopxEvent::default() + }); + outcomes.push(LoopxCreateTaskOutcome { + item: key, + kind: LoopxCreateTaskOutcomeKind::Created, + task_id: Some(task_id.clone()), + attempt: Some(attempt), + ..LoopxCreateTaskOutcome::default() + }); + created_task_ids.push(task_id); + } + } + } + state.record_processed_request(request.client_request_id); + let snapshot_revision = state.revision; + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + drop(_mutation); + self.broadcast_new_events(&persisted, start_cursor); + + // Enqueue only the first created task per repository. The rest stay + // Preparing/Queued and are chained by schedule_next_for_repository + // after each settlement, keeping execution order deterministic + // (creation order) instead of letting concurrent drives race for the + // repository slot (which could start the last-created task first). + let mut enqueued_repositories: std::collections::HashSet = + std::collections::HashSet::new(); + for task_id in &created_task_ids { + let repository_id = { + let state = self.state.read().await; + match state.tasks.iter().find(|task| &task.task_id == task_id) { + Some(task) => task.identity.item.repository.canonical_id(), + None => continue, + } + }; + if !enqueued_repositories.insert(repository_id) { + continue; + } + self.enqueue_task(task_id.clone(), Duration::ZERO)?; + } + Ok(LoopxCreateTaskResponse { + outcomes, + snapshot_revision, + }) + } + + pub async fn action( + self: &Arc, + request: LoopxActionRequest, + ) -> Result { + self.ensure_writable().await?; + if request.action == LoopxActionKind::RetryEnvironment { + self.refresh_environment().await?; + return Ok(LoopxActionResponse { + current_revision: self.state.read().await.revision, + ..LoopxActionResponse::default() + }); + } + if request.action == LoopxActionKind::InstallLoopx { + return self.start_loopx_install(&request).await; + } + if request.action == LoopxActionKind::ResumeRepository { + return self.resume_repository(&request).await; + } + if request.action == LoopxActionKind::ResetAll { + return self.reset_all(&request).await; + } + if request.action == LoopxActionKind::Unsupported { + return Err("Unsupported LoopX action".to_string()); + } + let task_id = request + .task_id + .clone() + .ok_or_else(|| "taskId is required".to_string())?; + let (task, runtime) = { + let state = self.state.read().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + task: state + .tasks + .iter() + .find(|task| task.task_id == task_id) + .cloned(), + ..LoopxActionResponse::default() + }); + } + let task = state + .tasks + .iter() + .find(|task| task.task_id == task_id) + .cloned() + .ok_or_else(|| "LoopX task not found".to_string())?; + if request.action == LoopxActionKind::Resume + && matches!( + task.state, + LoopxTaskState::Preparing | LoopxTaskState::Queued | LoopxTaskState::Running + ) + { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: task.revision, + task: Some(task), + message: Some("Task is already queued or running".to_string()), + }); + } + if task.revision != request.expected_revision { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::RevisionConflict, + current_revision: task.revision, + task: Some(task), + message: Some("Task changed; refresh before applying the action".to_string()), + }); + } + ( + task, + state.runtime.get(&task_id).cloned().unwrap_or_default(), + ) + }; + + match request.action { + LoopxActionKind::Pause => { + self.pause_task(&task, &runtime, &request.client_request_id) + .await + } + LoopxActionKind::Abort => { + self.abort_task(&task, &runtime, &request.client_request_id) + .await + } + LoopxActionKind::Resume => self.resume_task(&task, &request.client_request_id).await, + LoopxActionKind::ResumeRepository + | LoopxActionKind::ResetAll + | LoopxActionKind::InstallLoopx => unreachable!(), + LoopxActionKind::Approve | LoopxActionKind::Reject => { + self.answer_gate(&task, &runtime, &request).await + } + LoopxActionKind::Archive => { + let response = self + .transition_action( + &task_id, + LoopxTaskState::Archived, + LoopxPhase::Finished, + &request.client_request_id, + ) + .await?; + // Explicit user action: archive is the only workflow that + // destroys the task worktree (and its bare repository when + // the last worktree is gone). Terminal states keep their + // worktrees so the user can inspect agent output first. + self.dispose_task_workspace(&task).await; + Ok(response) + } + LoopxActionKind::Restore => { + self.transition_action( + &task_id, + LoopxTaskState::RecoveryRequired, + LoopxPhase::Recovering, + &request.client_request_id, + ) + .await + } + LoopxActionKind::RetryEnvironment | LoopxActionKind::Unsupported => unreachable!(), + } + } + + async fn start_loopx_install( + self: &Arc, + request: &LoopxActionRequest, + ) -> Result { + let started_at = Instant::now(); + if request.client_request_id.trim().is_empty() { + return Err("clientRequestId is required".to_string()); + } + { + let state = self.state.read().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + message: Some("LoopX installation request was already applied".to_string()), + ..LoopxActionResponse::default() + }); + } + if state.environment.core.sidecar.status == LoopxEnvironmentFactStatus::Available { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + message: Some("A compatible LoopX runtime is already available".to_string()), + ..LoopxActionResponse::default() + }); + } + } + if self + .install_in_progress + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: self.state.read().await.revision, + message: Some("LoopX installation is already running".to_string()), + ..LoopxActionResponse::default() + }); + } + let current_revision = match self.mark_loopx_installing(&request.client_request_id).await { + Ok(revision) => revision, + Err(error) => { + self.install_in_progress.store(false, Ordering::Release); + return Err(error); + } + }; + log::info!( + "LoopX installation state persisted: request_id={}, revision={}, duration_ms={}", + request.client_request_id, + current_revision, + elapsed_ms_u64(started_at) + ); + let request_id = request.client_request_id.clone(); + let controller = Arc::clone(self); + tokio::spawn(async move { + let _install_guard = InProgressGuard(&controller.install_in_progress); + log::info!("LoopX installation background task started: request_id={request_id}"); + if let Err(error) = controller.run_loopx_install(&request_id).await { + log::error!( + "LoopX managed source installation failed: request_id={request_id}, error={error}" + ); + let _ = controller.mark_loopx_install_failed(&error).await; + } + }); + Ok(LoopxActionResponse { + status: LoopxActionStatus::Applied, + current_revision, + message: Some("LoopX installation started".to_string()), + ..LoopxActionResponse::default() + }) + } + + async fn run_loopx_install(self: &Arc, request_id: &str) -> Result<(), String> { + let progress = BufferedProgress::default(); + let operation_id = format!("install-loopx-{}", uuid::Uuid::new_v4()); + let started_at = Instant::now(); + log::info!( + "LoopX installation service call started: request_id={request_id}, operation_id={operation_id}" + ); + let result = self + .cli + .install_managed_source( + LoopxCliInstallManagedSourceRequest { + call: LoopxCliCallContext { + operation_id: operation_id.clone(), + deadline_at: None, + }, + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + let installed = result.map_err(|error| error.to_string())?; + log::info!( + "LoopX installation service call completed: request_id={request_id}, operation_id={operation_id}, version={}, source={}, commit={}, duration_ms={}", + installed.loopx_version, + installed.source_repository, + installed.source_commit, + elapsed_ms_u64(started_at) + ); + self.refresh_environment().await?; + Ok(()) + } + + async fn mark_loopx_installing(&self, request_id: &str) -> Result { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let checked_at = Some(now_ms()); + state.environment.revision = state.environment.revision.saturating_add(1); + state.environment.status = LoopxEnvironmentStatus::Checking; + state.environment.checked_at = checked_at; + state.environment.core.sidecar = LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Checking, + version: Some(LOOPX_PINNED_VERSION.to_string()), + detail: Some("Downloading runtime files from the official GitHub source".to_string()), + checked_at, + ..LoopxEnvironmentFact::default() + }; + state.record_processed_request(request_id.to_string()); + state.revision = state.revision.saturating_add(1); + let current_revision = state.revision; + let start_cursor = state.cursor; + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + source: LoopxEventSource::System, + message: "LoopX managed source installation started".to_string(), + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + self.broadcast_new_events(&persisted, start_cursor); + Ok(current_revision) + } + + async fn mark_loopx_install_failed(&self, error: &str) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let checked_at = Some(now_ms()); + state.environment.revision = state.environment.revision.saturating_add(1); + state.environment.status = LoopxEnvironmentStatus::Blocked; + state.environment.checked_at = checked_at; + state.environment.core.sidecar = unavailable_loopx_environment_fact(error, checked_at); + state.revision = state.revision.saturating_add(1); + let start_cursor = state.cursor; + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + level: LoopxEventLevel::Error, + source: LoopxEventSource::System, + message: format!("LoopX managed source installation failed: {error}"), + important: true, + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + self.broadcast_new_events(&persisted, start_cursor); + Ok(()) + } + + pub async fn handle_agent_terminal( + self: &Arc, + turn_id: &str, + status: LoopxAgentTurnStatus, + summary: Option, + blocks_repository: bool, + ) -> Result<(), String> { + let (task, runtime) = { + let state = self.state.read().await; + let Some((task_id, runtime)) = state + .runtime + .iter() + .find(|(_, runtime)| runtime.agent_turn_id.as_deref() == Some(turn_id)) + else { + return Ok(()); + }; + let Some(task) = state.tasks.iter().find(|task| &task.task_id == task_id) else { + return Ok(()); + }; + (task.clone(), runtime.clone()) + }; + if task.state != LoopxTaskState::Running { + return Ok(()); + } + log::info!( + "LoopX Agent terminal handling started: task_id={}, goal_id={}, agent_turn_id={}, status={:?}", + task.task_id, + task.goal_id.as_deref().unwrap_or("unknown"), + turn_id, + status + ); + if let Some(summary) = summary + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let bounded = bounded_agent_summary(summary); + let structured = parse_structured_summary(Some(summary)); + self.mutate_task(&task.task_id, None, |current, _| { + if current.generation != task.generation { + return; + } + current.last_agent_summary = Some(bounded); + current.structured_summary = structured; + current.last_agent_summary_at = Some(now_ms()); + current.revision = current.revision.saturating_add(1); + }) + .await?; + } + self.update_task_phase( + &task.task_id, + task.generation, + LoopxPhase::ValidatingProgress, + "Agent turn ended; verifying LoopX-owned durable settlement", + ) + .await?; + let progress = BufferedProgress::default(); + let settlement_started = Instant::now(); + let result = self + .cli + .verify_turn_settlement( + LoopxCliSettleTurnRequest { + context: self.goal_context(&task, &runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + turn_id: runtime.loopx_turn_id.clone().unwrap_or_default(), + settlement_token: runtime.settlement_token.clone().unwrap_or_default(), + expected_durable_revision: runtime + .expected_durable_revision + .clone() + .unwrap_or_default(), + agent_status: status, + }, + &progress, + ) + .await; + match &result { + Ok(settlement) => log::info!( + "LoopX turn settlement completed: task_id={}, goal_id={}, loopx_turn_id={}, status={:?}, duration_ms={}, scheduler_hint_ms={:?}", + task.task_id, + task.goal_id.as_deref().unwrap_or("unknown"), + settlement.turn_id, + settlement.status, + settlement_started.elapsed().as_millis(), + settlement.scheduler_hint_ms + ), + Err(error) => log::warn!( + "LoopX turn settlement failed: task_id={}, goal_id={}, duration_ms={}, error={}", + task.task_id, + task.goal_id.as_deref().unwrap_or("unknown"), + settlement_started.elapsed().as_millis(), + error + ), + } + self.record_progress(progress.take()).await?; + let finish_result = if let (Some(session_id), Some(agent_turn_id)) = + (runtime.session_id.clone(), runtime.agent_turn_id.clone()) + { + self.agent + .finish(LoopxAgentFinishRequest { + operation_id: format!("finish-agent-{}", uuid::Uuid::new_v4()), + task_id: task.task_id.clone(), + generation: task.generation, + worktree_path: task.workspace_path.clone().unwrap_or_default(), + session_id, + turn_id: agent_turn_id, + }) + .await + .map_err(|error| error.to_string()) + } else { + Ok(LoopxAgentFinishResult::default()) + }; + match &finish_result { + Ok(finish) => log::info!( + "LoopX transient Agent session finished: task_id={}, session_id={}, discarded={}", + task.task_id, + finish.session_id, + finish.discarded + ), + Err(error) => log::warn!( + "LoopX transient Agent session cleanup failed: task_id={}, error={}", + task.task_id, + error + ), + } + match (result, finish_result) { + (Ok(settlement), Ok(_)) => { + self.apply_settlement( + &task, + settlement, + status, + summary.as_deref(), + blocks_repository, + ) + .await + } + (Err(error), _) => self.fail_task(&task.task_id, error.to_string()).await, + (Ok(settlement), Err(error)) => { + // A settled turn already fulfilled every LoopX contract + // obligation (durable writeback + quota receipt). Transient + // agent session teardown is host-side hygiene; a cleanup + // failure (for example the coordination store schema guard on + // a shared data root) must not discard the durable outcome. + log::warn!( + "LoopX transient Agent session cleanup failed after successful settlement; keeping the durable result: task_id={} error={}", + task.task_id, + error + ); + self.apply_settlement( + &task, + settlement, + status, + summary.as_deref(), + blocks_repository, + ) + .await + } + } + } + + pub(super) async fn handle_agent_activity(&self, turn_id: &str) -> Result<(), String> { + let task_id = { + let state = self.state.read().await; + state + .runtime + .iter() + .find(|(_, runtime)| runtime.agent_turn_id.as_deref() == Some(turn_id)) + .map(|(task_id, _)| task_id.clone()) + }; + let Some(task_id) = task_id else { + return Ok(()); + }; + self.mutate_task(&task_id, None, |task, _| { + if task.state != LoopxTaskState::Running { + return; + } + task.last_output_at = Some(now_ms()); + task.revision = task.revision.saturating_add(1); + }) + .await?; + Ok(()) + } + + pub(super) async fn handle_agent_tool_activity( + &self, + turn_id: &str, + activity: ToolActivityProjection, + ) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let Some(task_id) = state + .runtime + .iter() + .find(|(_, runtime)| runtime.agent_turn_id.as_deref() == Some(turn_id)) + .map(|(task_id, _)| task_id.clone()) + else { + return Ok(()); + }; + let Some(task_index) = state.tasks.iter().position(|task| task.task_id == task_id) else { + return Ok(()); + }; + if state.tasks[task_index].state != LoopxTaskState::Running { + return Ok(()); + } + + let now = now_ms(); + { + let task = &mut state.tasks[task_index]; + task.last_output_at = Some(now); + task.updated_at = now; + task.current_tool = activity.current_tool.clone(); + task.revision = task.revision.saturating_add(1); + } + let updated = state.tasks[task_index].clone(); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + task_id: Some(updated.task_id.clone()), + generation: Some(updated.generation), + revision: Some(updated.revision), + kind: LoopxEventKind::Log, + level: if activity.important { + LoopxEventLevel::Error + } else { + LoopxEventLevel::Info + }, + source: LoopxEventSource::Agent, + phase: Some(updated.phase), + message: activity.message, + important: activity.important, + tool_name: Some(activity.tool_name), + details: activity.details, + occurred_at: now, + ..LoopxEvent::default() + }); + let persisted = state.clone(); + let event = persisted.events.last().cloned(); + drop(state); + self.store.save(&persisted).await?; + if let Some(event) = event { + let _ = self.event_sender.send(event); + } + Ok(()) + } + + async fn drive_task(self: &Arc, task_id: String) -> Result<(), String> { + let task = self.task(&task_id).await?; + if !matches!( + task.state, + LoopxTaskState::Preparing | LoopxTaskState::Queued + ) { + return Ok(()); + } + if !self.reserve_repository(&task).await { + self.transition_task( + &task_id, + task.generation, + LoopxTaskState::Queued, + LoopxPhase::Queued, + "Another task for this repository is running", + ) + .await?; + return Ok(()); + } + // The goal binding survives restarts, but the workspace directory + // may not (removed by a concurrent instance, a reset, or manual + // cleanup). A deleted worktree also loses its `.loopx/registry.json`, + // so re-running prepare alone would leave the goal disconnected from + // a fresh project registry. Unbind first; the prepare + plan_item + + // create_goal flow below re-adds the worktree and reconnects the same + // deterministic goal id, and the frontier (including pending gates) + // resurfaces from LoopX. + if task_has_bound_goal(&task) && bound_workspace_missing(&task) { + log::warn!( + "LoopX bound workspace is missing, re-preparing and reconnecting the goal: task_id={} goal={} path={}", + task.task_id, + task.goal_id.as_deref().unwrap_or("-"), + task.workspace_path.as_deref().unwrap_or("-"), + ); + self.mutate_task(&task_id, None, |current, current_runtime| { + if current.generation != task.generation { + return; + } + current.goal_id = None; + current.goal_state = None; + current.pending_gate_id = None; + current.pending_gate_message = None; + current.pending_gate_action_kind = None; + current.current_turn_id = None; + current.current_tool = None; + current.current_todo = None; + current.settlement = LoopxSettlementSummary::default(); + current.revision = current.revision.saturating_add(1); + current_runtime.expected_durable_revision = None; + current_runtime.loopx_turn_id = None; + current_runtime.settlement_token = None; + current_runtime.session_id = None; + current_runtime.agent_turn_id = None; + }) + .await?; + } + let workspace_result = self + .workspace + .prepare(LoopxWorkspacePrepareRequest { + operation_id: format!("workspace-{}-{}", task.task_id, task.generation), + task_id: task.task_id.clone(), + item: task.identity.item.clone(), + }) + .await; + let workspace = workspace_result.map_err(|error| error.to_string())?; + if !workspace.repository_verified { + return Err("Prepared worktree does not match the requested repository".to_string()); + } + self.bind_workspace(&task_id, task.generation, &workspace) + .await?; + let task = self.task(&task_id).await?; + if task_has_bound_goal(&task) { + return self.drive_turn(task_id).await; + } + let runtime = self.runtime(&task_id).await; + let progress = BufferedProgress::default(); + let intake = self + .cli + .plan_item( + LoopxCliPlanItemRequest { + context: self.goal_context(&task, &runtime), + item: task.identity.item.clone(), + title: task.identity.title.clone(), + state: task.identity.state, + labels: task.identity.labels.clone(), + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + let goal_id = goal_id_for(&task.identity); + let progress = BufferedProgress::default(); + let created = self + .cli + .create_goal( + LoopxCliCreateGoalRequest { + context: self.goal_context(&task, &runtime), + goal_id: goal_id.clone(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + intake, + granted_scopes: task.granted_scopes.clone(), + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + let created_goal_id = created.goal_id.clone(); + self.bind_goal(&task_id, task.generation, created).await?; + log::info!( + "LoopX goal created: task_id={} goal={} agent={} worktree={}", + task_id, + created_goal_id, + task.agent_id.as_deref().unwrap_or("bitfun-agent"), + task.workspace_path.as_deref().unwrap_or("-"), + ); + self.drive_turn(task_id).await + } + + /// Mirrors a terminal state already reported by the authoritative LoopX + /// Goal and advances the next task in the repository queue. + async fn complete_projected_goal( + self: &Arc, + task: &LoopxTaskSnapshot, + message: &str, + ) -> Result<(), String> { + let updated = self + .transition_task( + &task.task_id, + task.generation, + LoopxTaskState::Completed, + LoopxPhase::Finished, + message, + ) + .await?; + self.record_current_todo(&updated.task_id, updated.generation, None) + .await?; + self.schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&updated.task_id), + ) + .await; + Ok(()) + } + + async fn drive_turn(self: &Arc, task_id: String) -> Result<(), String> { + let task = self.task(&task_id).await?; + let runtime = self.runtime(&task_id).await; + let progress = BufferedProgress::default(); + let inspected = self + .cli + .inspect_goal( + LoopxCliInspectGoalRequest { + context: self.goal_context(&task, &runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + let selected = inspected.selected_todo.as_ref(); + log::info!( + "LoopX inspect goal: task_id={} goal={} decision={:?} state={:?} open_todos={} waiting_user={} selected_todo={} selected_kind={} claimed_by={} revision={} hint_ms={:?} over_budget={}", + task.task_id, + inspected.goal_id, + inspected.run_decision, + inspected.state, + inspected.open_todo_count, + inspected.waiting_user_todo_count, + selected.map(|t| t.todo_id.as_str()).unwrap_or("-"), + selected.map(|t| t.action_kind.as_str()).unwrap_or("-"), + selected.map(|t| t.claimed_by.as_str()).unwrap_or("-"), + inspected.durable_revision, + inspected.scheduler_hint_ms, + inspected.envelope_over_budget, + ); + self.record_goal_state(&task, inspected.state).await?; + self.record_current_todo(&task_id, task.generation, inspected.selected_todo.clone()) + .await?; + match inspected.run_decision { + LoopxCliRunDecision::Wait => { + if inspected.state == LoopxCliGoalState::Archived { + return self + .complete_projected_goal(&task, "LoopX Goal was archived") + .await; + } + self.transition_task( + &task_id, + task.generation, + LoopxTaskState::Queued, + LoopxPhase::Queued, + "LoopX is waiting before the next bounded turn", + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task_id), + ) + .await; + // loopx 0.5.1 never emits a numeric scheduler hint, and a + // waiting goal with no requeue would sleep forever. The host + // owns the heartbeat cadence: honor an explicit hint when one + // exists, otherwise fall back to a bounded polling interval. + let delay = inspected + .scheduler_hint_ms + .unwrap_or(WAIT_RESCHEDULE_FALLBACK_MS); + log::info!( + "LoopX wait requeue: task_id={} goal={} delay_ms={}", + task_id, + inspected.goal_id, + delay, + ); + self.enqueue_task(task_id, Duration::from_millis(delay))?; + Ok(()) + } + LoopxCliRunDecision::WaitingForUser => { + let gate = inspected.pending_user_gate.ok_or_else(|| { + "LoopX requested a user decision without an answerable gate".to_string() + })?; + if is_read_only_user_gate(gate.action_kind.as_deref()) { + match self + .auto_answer_gate( + &task, + &runtime, + &gate, + LoopxCliGateDecision::Approve, + "Auto-approved by BitFun: read-only public issue metadata access." + .to_string(), + format!( + "Read-only user gate auto-approved by BitFun: {}", + gate.message + ), + ) + .await + { + Ok(()) => return Ok(()), + Err(error) => { + // Interactive approval stays available as the + // fallback when the automatic answer fails. + log::warn!( + "LoopX read-only gate auto-approval failed, falling back to interactive approval: task_id={} gate={} error={}", + task.task_id, + gate.gate_id, + error + ); + } + } + } + if is_reuse_merge_user_gate(gate.action_kind.as_deref(), &gate.message) { + let repository = task.identity.item.repository.clone(); + match self + .cli + .viewer_merge_authority(&self.goal_context(&task, &runtime), &repository) + .await + { + // Authority confirmed or unknown: leave the decision + // to the owner. + Ok(Some(true)) | Ok(None) => {} + Ok(Some(false)) => { + let pr_label = reuse_merge_pr_label(&gate.message); + match self + .auto_answer_gate( + &task, + &runtime, + &gate, + LoopxCliGateDecision::Reject, + format!( + "Auto-rejected by BitFun: the authenticated GitHub identity has no merge authority for {}; the agent must propose an alternative route (track the upstream PR, or an independent patch).", + repository.label() + ), + format!( + "Merge gate auto-rejected: no merge authority for {} ({}); the agent will need an alternative route", + repository.label(), + pr_label + ), + ) + .await + { + Ok(()) => return Ok(()), + Err(error) => log::warn!( + "LoopX merge-gate auto-reject failed, falling back to interactive: task_id={} gate={} error={}", + task.task_id, + gate.gate_id, + error + ), + } + } + Err(error) => { + log::warn!( + "LoopX merge authority probe failed, surfacing gate interactively: task_id={} gate={} error={}", + task.task_id, + gate.gate_id, + error + ); + } + } + } + let LoopxCliUserGate { + gate_id, + message, + action_kind, + } = gate; + let durable_revision = inspected.durable_revision.clone(); + let updated = self + .mutate_task(&task_id, None, |current, current_runtime| { + if current.generation != task.generation { + return; + } + current.state = LoopxTaskState::WaitingForUser; + current.phase = LoopxPhase::WaitingForApproval; + current.pending_gate_id = Some(gate_id.clone()); + current.pending_gate_message = Some(message.clone()); + current.pending_gate_action_kind = action_kind.clone(); + current.revision = current.revision.saturating_add(1); + current_runtime.expected_durable_revision = Some(durable_revision.clone()); + }) + .await?; + let mut details = BTreeMap::new(); + details.insert("gateId".to_string(), gate_id.clone()); + if let Some(action_kind) = action_kind.clone() { + details.insert("actionKind".to_string(), action_kind); + } + self.append_task_event_with_details( + &updated, + LoopxEventKind::ApprovalRequired, + &message, + true, + details, + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task_id), + ) + .await; + Ok(()) + } + LoopxCliRunDecision::Complete => { + return self + .complete_projected_goal(&task, "LoopX goal completed") + .await; + } + LoopxCliRunDecision::Failed => { + self.fail_task(&task_id, "LoopX reported a failed goal".to_string()) + .await + } + LoopxCliRunDecision::RunNow => { + self.sync_concurrent_user_gate(&task, inspected.pending_user_gate.as_ref()) + .await?; + // The contradiction witness is the envelope's own action + // projection, not the `open_count` scalar: in the pinned + // v0.5.1 outer-controller payload that counter comes from the + // agent-claim-scoped hot-lane summary and can legitimately be + // zero while `action.selected_todo` still names an open, + // claimed todo. Only refuse when the envelope itself asserts + // there is nothing to do; `quota should-run --turn-envelope` + // remains the authoritative execution gate either way. + let has_selected_todo = inspected + .selected_todo + .as_ref() + .is_some_and(|todo| !todo.todo_id.is_empty()); + if run_now_is_frontier_contradiction( + inspected.open_todo_count, + inspected.waiting_user_todo_count, + has_selected_todo, + ) { + // Runtime-data correction (2026-09-05 five-issue run): the + // pinned CLI v0.5.1 does NOT treat a todo-less `RunNow` + // frontier as terminal. When every todo is done or blocked + // and the goal vision is still open, it projects + // `should_run=true` plus an autonomous replan obligation + // and expects the host to drive one bounded replan turn + // bound to that obligation: the agent then writes back a + // successor todo, a typed terminal outcome (for example + // `coverage_backed_no_followup`, after which the goal + // projects Complete), or a concrete blocker. The quota + // guard admits exactly that turn and the settlement + // validates it by the `autonomous_replan` effect id. + // Parking here stranded every task of the five-issue run + // before any goal could close. + let replan_frontier = todoless_run_now_frontier( + inspected.pending_replan_obligation_id.as_deref(), + ); + if replan_frontier == TodolessRunNowFrontier::DriveReplanTurn { + log::info!( + "LoopX plan exhausted with an open autonomous replan obligation; driving one replan turn: task_id={} goal={} obligation={}", + task_id, + inspected.goal_id, + inspected.pending_replan_obligation_id.as_deref().unwrap_or("-"), + ); + // Fall through to the normal turn build below: the + // guard produces the `AutonomousReplan` binding and + // the re-entry instruction carries the replan flags. + } else { + // True contradiction: no open todo, no waiting user + // decision, no selected action, and no replan + // obligation the CLI would let the host drive. Park + // with an explicit reason so the recovery card can + // explain the plan is exhausted and guide the owner. + // The task keeps its worktree, evidence, and commits, + // and the repository slot yields to queued siblings. + if task.state == LoopxTaskState::RecoveryRequired + && task.recovery_reason.as_deref() == Some(LOOPX_PLAN_EXHAUSTED_REASON) + { + // Already parked with this exact diagnosis; do not + // churn another transition/event on a re-drive. + return Ok(()); + } + return self.park_plan_exhausted(&task, &inspected.goal_id).await; + } + } + if inspected.envelope_over_budget { + let message = "LoopX turn envelope exceeded its compaction budget (route contract_error); the Goal durable state for this Issue must shrink before work can resume. BitFun keeps the task queued and retries with backoff."; + let updated = self + .transition_task( + &task_id, + task.generation, + LoopxTaskState::Queued, + LoopxPhase::Queued, + message, + ) + .await?; + self.append_task_event(&updated, LoopxEventKind::StateChanged, message, true) + .await?; + self.schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&updated.task_id), + ) + .await; + self.enqueue_task(task_id, Duration::from_millis(WAIT_RESCHEDULE_FALLBACK_MS))?; + return Ok(()); + } + if task.state == LoopxTaskState::RecoveryRequired { + // Restart-interrupted runs land here; the owner decides + // via the explicit recovery action in the UI (nothing + // silent, nothing forged, worktree and evidence kept). + return Ok(()); + } + if let Some(todo) = inspected.selected_todo.as_ref() { + if is_loopx_monitor_action(&todo.action_kind) { + if let Some(hold_ms) = + monitor_recheck_hold_ms(task.settlement.settled_at, now_ms()) + { + // v0.5.1 compatibility cadence: the monitor todo + // is projected RunNow, but the external state it + // watches was verified by the turn that just + // settled. Park the re-check (yielding the + // repository slot to queued sibling issues) and + // re-drive after the remaining interval. + let message = format!( + "LoopX monitor re-check held back by the host compatibility cadence; next re-check in {} seconds", + hold_ms / 1000 + ); + log::info!( + "LoopX monitor recheck held: task_id={} goal={} action={} hold_ms={}", + task_id, + inspected.goal_id, + todo.action_kind, + hold_ms, + ); + let updated = self + .transition_task( + &task_id, + task.generation, + LoopxTaskState::Queued, + LoopxPhase::Queued, + &message, + ) + .await?; + self.schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&task_id), + ) + .await; + self.enqueue_task(task_id, Duration::from_millis(hold_ms))?; + return Ok(()); + } + } + } + let progress = BufferedProgress::default(); + let built = self + .cli + .build_turn( + LoopxCliBuildTurnRequest { + context: self.goal_context(&task, &runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + expected_durable_revision: inspected.durable_revision, + }, + &progress, + ) + .await; + let turn = match built { + Ok(turn) => turn, + Err(error) if error.kind == LoopxCliErrorKind::Conflict && error.retryable => { + // Transient durable-state race: a concurrent bootstrap + // or global-registry sync landed between this task's + // inspect and its quota guard. The envelope is healthy, + // so requeue with a short backoff instead of failing + // the host job (mirrors the envelope-over-budget + // degradation; the next drive re-reads fresh state). + let message = format!( + "LoopX durable state changed while building the turn ({}); requeueing with backoff", + error.message + ); + log::warn!( + "LoopX turn build conflict, requeueing: task_id={} goal={} detail={}", + task_id, + task.goal_id.as_deref().unwrap_or("-"), + error.message + ); + let updated = self + .transition_task( + &task_id, + task.generation, + LoopxTaskState::Queued, + LoopxPhase::RetryBackoff, + &message, + ) + .await?; + self.record_progress(progress.take()).await?; + self.append_task_event( + &updated, + LoopxEventKind::StateChanged, + &message, + false, + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task_id), + ) + .await; + self.enqueue_task(task_id, Duration::from_millis(TURN_CONFLICT_RETRY_MS))?; + return Ok(()); + } + Err(error) => return Err(error.to_string()), + }; + self.record_progress(progress.take()).await?; + self.bind_turn(&task, &turn).await?; + let mut agent_instruction = turn.agent_instruction; + if let Some(note) = self.take_pending_host_note(&task.task_id).await { + log::info!( + "LoopX host note appended to turn instruction: task_id={} note_bytes={}", + task.task_id, + note.len() + ); + agent_instruction.push_str("\n\n---\n[BitFun host note] "); + agent_instruction.push_str(¬e); + } + log::info!( + "LoopX turn built, starting agent: task_id={} goal={} turn={} deadline_ms={:?} instruction_bytes={}", + task.task_id, + turn.goal_id, + turn.turn_id, + turn.deadline_at, + agent_instruction.len(), + ); + let started = self + .agent + .start(LoopxAgentStartRequest { + operation_id: format!("agent-{}-{}", task.task_id, task.generation), + task_id: task.task_id.clone(), + generation: task.generation, + worktree_path: task.workspace_path.clone().unwrap_or_default(), + instruction: agent_instruction, + model_id: task.model_id.clone().unwrap_or_else(|| "auto".to_string()), + granted_scopes: task.granted_scopes.clone(), + metadata: LoopxAgentTurnMetadata { + goal_id: task.goal_id.clone().unwrap_or_default(), + loopx_turn_id: turn.turn_id, + item: task.identity.item.clone(), + attempt: task.identity.attempt, + }, + }) + .await + .map_err(|error| error.to_string())?; + self.bind_agent_run(&task, started).await + } + } + } + + /// Best-effort teardown of a task's agent run (cancel then finish). A stale + /// session — for example one persisted before a host restart — must not + /// abort pause or reset. The local record owns only host-job cleanup; LoopX + /// remains authoritative for Goal lifecycle, so teardown failures are + /// logged and the operator action continues. + async fn teardown_agent_run( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + ) { + let (Some(session_id), Some(turn_id)) = + (runtime.session_id.as_ref(), runtime.agent_turn_id.as_ref()) + else { + return; + }; + if let Err(error) = self + .agent + .cancel(LoopxAgentCancelRequest { + operation_id: format!("teardown-agent-{}", uuid::Uuid::new_v4()), + target_operation_id: runtime.operation_id.clone(), + task_id: task.task_id.clone(), + generation: task.generation, + session_id: session_id.clone(), + turn_id: turn_id.clone(), + }) + .await + { + log::warn!( + "LoopX agent cancel skipped for task {}: {}", + task.task_id, + error + ); + } + if let Err(error) = self + .agent + .finish(LoopxAgentFinishRequest { + operation_id: format!("teardown-agent-finish-{}", uuid::Uuid::new_v4()), + task_id: task.task_id.clone(), + generation: task.generation, + worktree_path: task.workspace_path.clone().unwrap_or_default(), + session_id: session_id.clone(), + turn_id: turn_id.clone(), + }) + .await + { + log::warn!( + "LoopX agent finish skipped for task {}: {}", + task.task_id, + error + ); + } + } + + async fn pause_task( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + request_id: &str, + ) -> Result { + self.transition_task( + &task.task_id, + task.generation, + LoopxTaskState::Cancelling, + LoopxPhase::Cancelling, + "Cancelling the active LoopX task", + ) + .await?; + self.teardown_agent_run(task, runtime).await; + let progress = BufferedProgress::default(); + let _ = self + .cli + .cancel( + LoopxCliCancelRequest { + call: LoopxCliCallContext { + operation_id: format!("cancel-cli-{}", uuid::Uuid::new_v4()), + deadline_at: None, + }, + target_operation_id: runtime.operation_id.clone(), + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + let response = self + .transition_action( + &task.task_id, + LoopxTaskState::Stopped, + LoopxPhase::Finished, + request_id, + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task.task_id), + ) + .await; + Ok(response) + } + + async fn abort_task( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + request_id: &str, + ) -> Result { + self.transition_task( + &task.task_id, + task.generation, + LoopxTaskState::Cancelling, + LoopxPhase::Cancelling, + "Aborting the active LoopX task", + ) + .await?; + self.teardown_agent_run(task, runtime).await; + let progress = BufferedProgress::default(); + let _ = self + .cli + .cancel( + LoopxCliCancelRequest { + call: LoopxCliCallContext { + operation_id: format!("abort-cli-{}", uuid::Uuid::new_v4()), + deadline_at: None, + }, + target_operation_id: runtime.operation_id.clone(), + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + let response = self + .transition_action( + &task.task_id, + LoopxTaskState::Aborted, + LoopxPhase::Finished, + request_id, + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task.task_id), + ) + .await; + Ok(response) + } + + async fn resume_task( + self: &Arc, + task: &LoopxTaskSnapshot, + request_id: &str, + ) -> Result { + if !matches!( + task.state, + LoopxTaskState::Stopped | LoopxTaskState::Failed | LoopxTaskState::RecoveryRequired + ) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Rejected, + current_revision: task.revision, + task: Some(task.clone()), + message: Some( + "Only stopped, failed, or recovery-required tasks can resume".to_string(), + ), + }); + } + let updated = self + .mutate_task(&task.task_id, Some(request_id), |task, runtime| { + task.generation = task.generation.saturating_add(1); + task.revision = task.revision.saturating_add(1); + task.state = LoopxTaskState::Queued; + task.phase = LoopxPhase::Recovering; + task.current_turn_id = None; + task.pending_gate_id = None; + task.pending_gate_message = None; + task.pending_gate_action_kind = None; + task.error = None; + task.recovery_reason = None; + runtime.operation_id = format!("resume-{}-{}", task.task_id, task.generation); + runtime.session_id = None; + runtime.agent_turn_id = None; + runtime.loopx_turn_id = None; + runtime.settlement_token = None; + runtime.expected_durable_revision = None; + }) + .await?; + let task_id = task.task_id.clone(); + self.enqueue_task(task_id, Duration::ZERO)?; + Ok(LoopxActionResponse { + current_revision: updated.revision, + task: Some(updated), + ..LoopxActionResponse::default() + }) + } + + async fn resume_repository( + self: &Arc, + request: &LoopxActionRequest, + ) -> Result { + let repository = request + .repository + .as_ref() + .ok_or_else(|| "repository is required for resume_repository".to_string())?; + let repository_id = repository.canonical_id(); + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + message: Some("Repository resume was already applied".to_string()), + ..LoopxActionResponse::default() + }); + } + if state.revision != request.expected_revision { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::RevisionConflict, + current_revision: state.revision, + message: Some( + "Task list changed; refresh before resuming the repository".to_string(), + ), + ..LoopxActionResponse::default() + }); + } + + let task_indexes = state + .tasks + .iter() + .enumerate() + .filter_map(|(index, task)| { + is_repository_recovery_candidate(task, &repository_id).then_some(index) + }) + .collect::>(); + let start_cursor = state.cursor; + let now = now_ms(); + let mut task_ids = Vec::with_capacity(task_indexes.len()); + for task_index in task_indexes { + let task_id = state.tasks[task_index].task_id.clone(); + let mut runtime = state.runtime.remove(&task_id).unwrap_or_default(); + { + let task = &mut state.tasks[task_index]; + task.generation = task.generation.saturating_add(1); + task.revision = task.revision.saturating_add(1); + task.state = LoopxTaskState::Queued; + task.phase = LoopxPhase::Recovering; + task.current_turn_id = None; + task.error = None; + task.recovery_reason = None; + task.updated_at = now; + runtime.operation_id = format!("resume-{}-{}", task.task_id, task.generation); + runtime.session_id = None; + runtime.agent_turn_id = None; + runtime.loopx_turn_id = None; + runtime.settlement_token = None; + runtime.expected_durable_revision = None; + } + let updated = state.tasks[task_index].clone(); + state.runtime.insert(task_id.clone(), runtime); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + task_id: Some(task_id.clone()), + generation: Some(updated.generation), + revision: Some(updated.revision), + kind: LoopxEventKind::StateChanged, + source: LoopxEventSource::Controller, + phase: Some(LoopxPhase::Recovering), + message: "Task queued by repository resume".to_string(), + occurred_at: now, + ..LoopxEvent::default() + }); + task_ids.push(task_id); + } + state.record_processed_request(request.client_request_id.clone()); + let resumed_count = task_ids.len(); + let current_revision = state.revision; + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + drop(_mutation); + self.broadcast_new_events(&persisted, start_cursor); + for task_id in task_ids { + self.enqueue_task(task_id, Duration::ZERO)?; + } + Ok(LoopxActionResponse { + current_revision, + message: Some(format!("Queued {resumed_count} repository tasks")), + ..LoopxActionResponse::default() + }) + } + + async fn reset_all( + self: &Arc, + request: &LoopxActionRequest, + ) -> Result { + if self + .reset_in_progress + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: self.state.read().await.revision, + message: Some("LoopX reset is already in progress".to_string()), + ..LoopxActionResponse::default() + }); + } + let _reset_guard = InProgressGuard(&self.reset_in_progress); + let (tasks, runtimes, previous_stream_id, environment) = { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + if state.has_processed_request(&request.client_request_id) { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Duplicate, + current_revision: state.revision, + message: Some("LoopX reset was already applied".to_string()), + ..LoopxActionResponse::default() + }); + } + if state.revision != request.expected_revision { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::RevisionConflict, + current_revision: state.revision, + message: Some("LoopX state changed; refresh before resetting".to_string()), + ..LoopxActionResponse::default() + }); + } + let tasks = state.tasks.clone(); + let runtimes = state.runtime.clone(); + let environment = state.environment.clone(); + for task in &mut state.tasks { + if matches!( + task.state, + LoopxTaskState::Preparing + | LoopxTaskState::Queued + | LoopxTaskState::Running + | LoopxTaskState::Cancelling + ) { + task.state = LoopxTaskState::Cancelling; + task.phase = LoopxPhase::Cancelling; + task.revision = task.revision.saturating_add(1); + task.updated_at = now_ms(); + } + } + state.record_processed_request(request.client_request_id.clone()); + state.revision = state.revision.saturating_add(1); + let persisted = state.clone(); + let previous_stream_id = state.stream_id.clone(); + drop(state); + self.store.save(&persisted).await?; + (tasks, runtimes, previous_stream_id, environment) + }; + + for task in &tasks { + let runtime = runtimes.get(&task.task_id).cloned().unwrap_or_default(); + self.teardown_agent_run(task, &runtime).await; + if !runtime.operation_id.trim().is_empty() { + let progress = BufferedProgress::default(); + let _ = self + .cli + .cancel( + LoopxCliCancelRequest { + call: LoopxCliCallContext { + operation_id: format!("reset-cli-{}", uuid::Uuid::new_v4()), + deadline_at: None, + }, + target_operation_id: runtime.operation_id.clone(), + }, + &progress, + ) + .await; + } + let _ = self + .workspace + .cancel(LoopxWorkspaceCancelRequest { + operation_id: format!("reset-workspace-{}", uuid::Uuid::new_v4()), + target_operation_id: format!("workspace-{}-{}", task.task_id, task.generation), + task_id: task.task_id.clone(), + }) + .await; + } + + self.workspace + .reset(LoopxWorkspaceResetRequest { + operation_id: format!("reset-workspaces-{}", uuid::Uuid::new_v4()), + }) + .await + .map_err(|error| error.to_string())?; + let goal_ids = tasks + .iter() + .map(|task| goal_id_for(&task.identity)) + .collect::>() + .into_iter() + .collect::>(); + let reset_goals = if goal_ids.is_empty() { + LoopxCliResetGoalsResult::default() + } else { + let progress = BufferedProgress::default(); + let result = self + .cli + .reset_goals( + LoopxCliResetGoalsRequest { + call: LoopxCliCallContext { + operation_id: format!("reset-goals-{}", uuid::Uuid::new_v4()), + deadline_at: None, + }, + goal_ids, + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + result + }; + log::info!( + "LoopX reset goal cleanup completed: requested={}, retired={}, already_absent={}, archived={}, missing_runtime={}", + reset_goals.requested_goal_ids.len(), + reset_goals.retired_goal_ids.len(), + reset_goals.already_absent_goal_ids.len(), + reset_goals.archived_goal_ids.len(), + reset_goals.missing_runtime_goal_ids.len() + ); + self.agent + .reset(LoopxAgentResetRequest { + operation_id: format!("reset-history-{}", uuid::Uuid::new_v4()), + }) + .await + .map_err(|error| error.to_string())?; + + let mut fresh = LoopxPersistedState::new(now_ms()); + fresh.environment = environment; + self.store.clear().await?; + { + let _mutation = self.mutation_lock.lock().await; + *self.state.write().await = fresh.clone(); + self.active_tasks.lock().await.clear(); + self.active_repositories.lock().await.clear(); + self.previews.write().await.clear(); + *self.load_error.write().await = None; + } + let _ = self.event_sender.send(LoopxEvent { + stream_id: fresh.stream_id, + cursor: 0, + kind: LoopxEventKind::SnapshotInvalidated, + level: LoopxEventLevel::Info, + source: LoopxEventSource::Controller, + message: format!("LoopX reset replaced stream {previous_stream_id}"), + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + Ok(LoopxActionResponse { + current_revision: fresh.revision, + message: Some(format!( + "Cleared {} LoopX tasks, {} global goal routes, managed workspaces, runtime state, and persisted controller state", + tasks.len(), + reset_goals.retired_goal_ids.len() + + reset_goals.already_absent_goal_ids.len() + )), + ..LoopxActionResponse::default() + }) + } + + async fn answer_gate( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + request: &LoopxActionRequest, + ) -> Result { + if bound_workspace_missing(task) { + // A dead workspace cannot answer gates: the CLI spawn would fail + // with an invalid-directory error. The next drive re-prepares the + // workspace and reconnects the goal, then the gate resurfaces. + return Err( + "LoopX workspace is missing for this task; it will be re-prepared on the next run — retry the approval after the task leaves recovery and re-raises the gate" + .to_string(), + ); + } + let gate_id = request + .gate_id + .clone() + .ok_or_else(|| "gateId is required".to_string())?; + let progress = BufferedProgress::default(); + let result = self + .cli + .answer_gate( + LoopxCliAnswerGateRequest { + context: self.goal_context(task, runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + gate_id, + decision: if request.action == LoopxActionKind::Approve { + LoopxCliGateDecision::Approve + } else { + LoopxCliGateDecision::Reject + }, + note: request.note.clone(), + granted_scope: None, + }, + &progress, + ) + .await + .map_err(|error| error.to_string())?; + self.record_progress(progress.take()).await?; + if !result.applied { + return Ok(LoopxActionResponse { + status: LoopxActionStatus::Rejected, + current_revision: task.revision, + task: Some(task.clone()), + message: Some("LoopX did not apply the gate decision".to_string()), + }); + } + self.record_goal_state(task, result.goal_state).await?; + self.mutate_task(&task.task_id, None, |current, current_runtime| { + if current.generation != task.generation { + return; + } + current.revision = current.revision.saturating_add(1); + current_runtime.expected_durable_revision = Some(result.durable_revision.clone()); + }) + .await?; + let response = self + .transition_action( + &task.task_id, + LoopxTaskState::Queued, + LoopxPhase::Queued, + &request.client_request_id, + ) + .await?; + self.enqueue_task(task.task_id.clone(), Duration::ZERO)?; + Ok(response) + } + + /// Generic durable gate answer used by the automatic approvers. The + /// decision is recorded as a durable task event so the surface stays + /// auditable. + async fn auto_answer_gate( + self: &Arc, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + gate: &LoopxCliUserGate, + decision: LoopxCliGateDecision, + note: String, + event_message: String, + ) -> Result<(), String> { + log::info!( + "LoopX auto-answering user gate: task_id={} goal={} gate={} decision={:?} kind={:?}", + task.task_id, + task.goal_id.as_deref().unwrap_or("-"), + gate.gate_id, + decision, + gate.action_kind, + ); + let progress = BufferedProgress::default(); + let result = self + .cli + .answer_gate( + LoopxCliAnswerGateRequest { + context: self.goal_context(task, runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + gate_id: gate.gate_id.clone(), + decision, + note: Some(note), + granted_scope: None, + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + let result = result.map_err(|error| error.to_string())?; + if !result.applied { + return Err("LoopX did not apply the automatic gate answer".to_string()); + } + self.record_goal_state(task, result.goal_state).await?; + self.mutate_task(&task.task_id, None, |current, current_runtime| { + if current.generation != task.generation { + return; + } + current.revision = current.revision.saturating_add(1); + current.pending_gate_id = None; + current.pending_gate_message = None; + current.pending_gate_action_kind = None; + current_runtime.expected_durable_revision = Some(result.durable_revision.clone()); + }) + .await?; + let updated = self.task(&task.task_id).await?; + let mut details = BTreeMap::new(); + details.insert("gateId".to_string(), gate.gate_id.clone()); + if let Some(kind) = gate.action_kind.clone() { + details.insert("actionKind".to_string(), kind); + } + details.insert("autoAnswered".to_string(), "true".to_string()); + self.append_task_event_with_details( + &updated, + LoopxEventKind::StateChanged, + &event_message, + true, + details, + ) + .await?; + self.schedule_next_for_repository( + &task.identity.item.repository.canonical_id(), + Some(&task.task_id), + ) + .await; + self.enqueue_task(task.task_id.clone(), Duration::ZERO)?; + Ok(()) + } + + /// Takes the one-shot host note (if any) so the next agent instruction + /// carries it exactly once. + async fn take_pending_host_note(&self, task_id: &str) -> Option { + let note = self.runtime(task_id).await.pending_host_note.clone(); + if note.is_some() { + self.mutate_task(task_id, None, |_current, runtime| { + runtime.pending_host_note = None; + }) + .await + .ok(); + } + note + } + + async fn apply_settlement( + self: &Arc, + task: &LoopxTaskSnapshot, + settlement: LoopxCliSettleTurnResult, + agent_status: LoopxAgentTurnStatus, + failure_summary: Option<&str>, + blocks_repository: bool, + ) -> Result<(), String> { + let post_settlement_goal = + if inspects_goal_after_settlement(agent_status, settlement.status) { + let runtime = self.runtime(&task.task_id).await; + let progress = BufferedProgress::default(); + let inspected = self + .cli + .inspect_goal( + LoopxCliInspectGoalRequest { + context: self.goal_context(task, &runtime), + goal_id: task.goal_id.clone().unwrap_or_default(), + agent_id: task + .agent_id + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_ID.to_string()), + }, + &progress, + ) + .await; + self.record_progress(progress.take()).await?; + match inspected { + Ok(snapshot) => Some(snapshot), + Err(error) => { + log::warn!( + "LoopX post-settlement Goal inspection failed: task_id={}, error={}", + task.task_id, + error + ); + None + } + } + } else { + None + }; + // A NoDurableProgress settlement after a healthy agent turn usually + // means the workflow produced its artifacts outside the CLI write + // boundary (for example files under the system temp directory), so + // settlement could not validate them. Schedule exactly one corrective + // turn that re-submits the pending writebacks before parking the task + // for interactive recovery. The corrective turn is a normal turn with + // an explicit host note; nothing is forged and every step is recorded + // as a task event. + let compensate_durable_writeback = agent_status != LoopxAgentTurnStatus::Failed + && settlement.status == LoopxCliSettlementStatus::NoDurableProgress + && !self + .runtime(&task.task_id) + .await + .durable_compensation_pending; + let final_state = if compensate_durable_writeback { + LoopxTaskState::Queued + } else { + task_state_after_settlement( + agent_status, + settlement.status, + post_settlement_goal.as_ref(), + ) + }; + let phase = phase_after_settlement(final_state); + let updated = self + .mutate_task(&task.task_id, None, |task, runtime| { + task.state = final_state; + task.phase = phase; + task.recovery_reason = if final_state == LoopxTaskState::RecoveryRequired { + Some("settlement_unverified".to_string()) + } else { + None + }; + task.goal_state = post_settlement_goal + .as_ref() + .map(|goal| goal.state) + .or_else(|| { + Some(match settlement.status { + LoopxCliSettlementStatus::GoalCompleted => LoopxCliGoalState::Completed, + _ => LoopxCliGoalState::Active, + }) + }); + task.revision = task.revision.saturating_add(1); + task.current_turn_id = None; + let pending_gate = post_settlement_goal + .as_ref() + .and_then(|goal| goal.pending_user_gate.as_ref()); + task.pending_gate_id = pending_gate.map(|gate| gate.gate_id.clone()); + task.pending_gate_message = pending_gate.map(|gate| gate.message.clone()); + task.pending_gate_action_kind = + pending_gate.and_then(|gate| gate.action_kind.clone()); + task.deadline_at = None; + task.error = (agent_status == LoopxAgentTurnStatus::Failed) + .then(|| failure_summary.unwrap_or("Agent turn failed").to_string()); + task.settlement = LoopxSettlementSummary { + turn_id: Some(settlement.turn_id.clone()), + receipt_id: Some(settlement.receipt_id.clone()), + durable_revision: Some(settlement.after_revision.clone()), + settled_at: Some(now_ms()), + }; + runtime.session_id = None; + runtime.agent_turn_id = None; + if compensate_durable_writeback { + runtime.durable_compensation_pending = true; + runtime.pending_host_note = Some(LOOPX_DURABLE_COMPENSATION_NOTE.to_string()); + } + // A settled turn proves the quota contract works again; the + // one-shot compensation allowance must re-arm for a future + // unrelated NoDurableProgress episode. + if matches!( + settlement.status, + LoopxCliSettlementStatus::Settled + | LoopxCliSettlementStatus::AlreadySettled + | LoopxCliSettlementStatus::GoalCompleted + ) { + runtime.durable_compensation_pending = false; + } + runtime.expected_durable_revision = Some( + post_settlement_goal + .as_ref() + .map(|goal| goal.durable_revision.clone()) + .unwrap_or_else(|| settlement.after_revision.clone()), + ); + }) + .await?; + log::info!( + "LoopX task settlement applied: task_id={}, goal_id={}, final_state={:?}, phase={:?}, settlement_status={:?}", + updated.task_id, + updated.goal_id.as_deref().unwrap_or("unknown"), + updated.state, + updated.phase, + settlement.status + ); + // Loud, auditable degradation for the false-negative settlement: + // the durable writeback validated and the Goal projection decided + // the next state, but the turn's quota spend receipt is permanently + // missing. The host neither retries nor fabricates the receipt; the + // loss is recorded as a task event so it stays visible in the + // timeline and telemetry. Cancelled/interrupted turns and failed + // post-settlement inspections keep the loud recovery card instead. + if agent_status == LoopxAgentTurnStatus::Completed + && settlement.status == LoopxCliSettlementStatus::RetryRequired + && post_settlement_goal.is_some() + { + let message = format!( + "LoopX settlement for turn {} reported RetryRequired: the durable writeback validated but the quota spend receipt is missing; the task continues from the authoritative Goal projection ({:?}). The receipt is not retried or fabricated by the host.", + settlement.turn_id, + updated.state + ); + log::warn!( + "LoopX settlement quota receipt missing, continuing from goal projection: task_id={} turn={}", + updated.task_id, + settlement.turn_id + ); + self.append_task_event(&updated, LoopxEventKind::SettlementRecorded, &message, true) + .await?; + } + let yielded_repository; + if agent_status == LoopxAgentTurnStatus::Failed { + let reason = failure_summary.unwrap_or("Agent turn failed"); + self.append_task_event(&updated, LoopxEventKind::StateChanged, reason, true) + .await?; + if blocks_repository { + self.pause_repository_after_failure(&updated, reason) + .await?; + } + } else { + if final_state == LoopxTaskState::WaitingForUser { + let gate = post_settlement_goal + .as_ref() + .and_then(|goal| goal.pending_user_gate.as_ref()) + .ok_or_else(|| { + "LoopX projected waiting_for_user without an answerable gate".to_string() + })?; + // Read-only gates are policy answers, not consent: the owner + // decided that reading public issue content never needs a + // human, so answer them here exactly like the drive-turn + // inspector does (same durable boundary, host-attributed + // note). Interactive approval stays the fallback on failure. + if is_read_only_user_gate(gate.action_kind.as_deref()) { + let runtime = self.runtime(&task.task_id).await; + match self + .auto_answer_gate( + &updated, + &runtime, + gate, + LoopxCliGateDecision::Approve, + "Auto-approved by BitFun: read-only public issue metadata access." + .to_string(), + format!( + "Read-only user gate auto-approved by BitFun after settlement: {}", + gate.message + ), + ) + .await + { + Ok(()) => return Ok(()), + Err(error) => { + log::warn!( + "LoopX read-only gate auto-approval after settlement failed, falling back to interactive approval: task_id={} gate={} error={}", + task.task_id, + gate.gate_id, + error + ); + } + } + } + let mut details = BTreeMap::new(); + details.insert("gateId".to_string(), gate.gate_id.clone()); + if let Some(action_kind) = gate.action_kind.clone() { + details.insert("actionKind".to_string(), action_kind); + } + self.append_task_event_with_details( + &updated, + LoopxEventKind::ApprovalRequired, + &gate.message, + true, + details, + ) + .await?; + } else { + let (kind, message, important) = if compensate_durable_writeback { + ( + LoopxEventKind::StateChanged, + "LoopX durable writeback was not validated; scheduling one corrective turn to re-submit pending artifacts via the CLI write boundary", + true, + ) + } else { + match final_state { + LoopxTaskState::Completed => ( + LoopxEventKind::SettlementRecorded, + "LoopX goal completed", + false, + ), + LoopxTaskState::RecoveryRequired => ( + LoopxEventKind::StateChanged, + if settlement.status == LoopxCliSettlementStatus::RetryRequired { + "LoopX writeback validated but the quota spend settlement is missing; resume retries the turn with the current quota contract" + } else { + "LoopX turn requires recovery after settlement" + }, + true, + ), + _ => ( + LoopxEventKind::SettlementRecorded, + "LoopX turn settlement recorded", + false, + ), + } + }; + self.append_task_event(&updated, kind, message, important) + .await?; + } + if sticky_continue_after_settlement( + final_state, + post_settlement_goal.as_ref().map(|goal| goal.run_decision), + post_settlement_goal + .as_ref() + .and_then(|goal| goal.selected_todo.as_ref()) + .map(|todo| todo.action_kind.as_str()), + ) { + // Depth-first repository lane: the segment settled cleanly and + // the Goal is still runnable (RunNow), so the same task keeps + // the repository slot and continues with its next bounded + // segment instead of yielding to the next queued issue. The + // slot is intentionally not released here; reserve_repository + // accepts the same owner on the next drive. + self.enqueue_task(updated.task_id.clone(), Duration::ZERO)?; + } else { + yielded_repository = self + .schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&updated.task_id), + ) + .await; + if yielded_repository { + self.suppress_pending_task_rerun(&updated.task_id).await; + } else if matches!( + final_state, + LoopxTaskState::RecoveryRequired | LoopxTaskState::WaitingForUser + ) { + // Nothing queued could take the freed slot. Surface what the + // remaining repository tasks are stuck in so a stalled line + // shows up in the log instead of silent idling. + let stalled: Vec = { + let state = self.state.read().await; + state + .tasks + .iter() + .filter(|task| { + task.task_id != updated.task_id + && task.identity.item.repository.canonical_id() + == updated.identity.item.repository.canonical_id() + && !matches!( + task.state, + LoopxTaskState::Completed + | LoopxTaskState::Archived + | LoopxTaskState::Stopped + ) + }) + .map(|task| format!("{} {:?}", task.task_id, task.state)) + .collect() + }; + log::warn!( + "LoopX repository queue stalled after parking task {}: remaining non-terminal tasks {:?}", + updated.task_id, + stalled + ); + } + if should_requeue_after_settlement(final_state, yielded_repository) { + let task_id = task.task_id.clone(); + let delay = settlement.scheduler_hint_ms.unwrap_or(0); + self.enqueue_task(task_id, Duration::from_millis(delay))?; + } + } + } + Ok(()) + } + + async fn sync_concurrent_user_gate( + &self, + task: &LoopxTaskSnapshot, + gate: Option<&LoopxCliUserGate>, + ) -> Result<(), String> { + let unchanged = task.pending_gate_id.as_deref() == gate.map(|gate| gate.gate_id.as_str()) + && task.pending_gate_message.as_deref() == gate.map(|gate| gate.message.as_str()) + && task.pending_gate_action_kind.as_deref() + == gate.and_then(|gate| gate.action_kind.as_deref()); + if unchanged { + return Ok(()); + } + + let updated = self + .mutate_task(&task.task_id, None, |current, _| { + if current.generation != task.generation { + return; + } + current.pending_gate_id = gate.map(|gate| gate.gate_id.clone()); + current.pending_gate_message = gate.map(|gate| gate.message.clone()); + current.pending_gate_action_kind = gate.and_then(|gate| gate.action_kind.clone()); + current.revision = current.revision.saturating_add(1); + }) + .await?; + + if let Some(gate) = gate { + let mut details = BTreeMap::new(); + details.insert("gateId".to_string(), gate.gate_id.clone()); + if let Some(action_kind) = gate.action_kind.clone() { + details.insert("actionKind".to_string(), action_kind); + } + self.append_task_event_with_details( + &updated, + LoopxEventKind::ApprovalRequired, + &gate.message, + true, + details, + ) + .await?; + } + Ok(()) + } + + async fn pause_repository_after_failure( + &self, + failed_task: &LoopxTaskSnapshot, + reason: &str, + ) -> Result<(), String> { + let repository_id = failed_task.identity.item.repository.canonical_id(); + let message = format!( + "Repository queue paused after Issue #{} failed: {}", + failed_task.identity.item.number, + reason.chars().take(700).collect::() + ); + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let start_cursor = state.cursor; + let now = now_ms(); + let mut paused = Vec::new(); + for task in &mut state.tasks { + if task.task_id == failed_task.task_id + || task.identity.item.repository.canonical_id() != repository_id + || task.state != LoopxTaskState::Queued + { + continue; + } + task.state = LoopxTaskState::RecoveryRequired; + task.phase = LoopxPhase::Recovering; + task.error = Some(message.clone()); + task.recovery_reason = Some("repository_paused".to_string()); + task.current_turn_id = None; + task.deadline_at = None; + task.revision = task.revision.saturating_add(1); + task.updated_at = now; + paused.push(task.clone()); + } + for task in &paused { + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + task_id: Some(task.task_id.clone()), + generation: Some(task.generation), + revision: Some(task.revision), + kind: LoopxEventKind::StateChanged, + level: LoopxEventLevel::Error, + source: LoopxEventSource::Controller, + phase: Some(LoopxPhase::Recovering), + message: message.clone(), + important: true, + occurred_at: now, + ..LoopxEvent::default() + }); + } + state.environment.core.agent_model.status = LoopxEnvironmentFactStatus::Degraded; + state.environment.core.agent_model.detail = Some(reason.to_string()); + state.environment.core.agent_model.checked_at = Some(now); + state.environment.status = + derive_environment_status(&state.environment.core, &state.environment.optional); + state.environment.revision = state.environment.revision.saturating_add(1); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + level: LoopxEventLevel::Error, + source: LoopxEventSource::System, + message: "Agent model runtime failed; repository queue paused".to_string(), + important: true, + occurred_at: now, + ..LoopxEvent::default() + }); + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + drop(_mutation); + self.broadcast_new_events(&persisted, start_cursor); + Ok(()) + } + + async fn reserve_repository(&self, task: &LoopxTaskSnapshot) -> bool { + let repo = task.identity.item.repository.canonical_id(); + let mut active = self.active_repositories.lock().await; + match active.get(&repo) { + Some(owner) => owner == &task.task_id, + None => { + active.insert(repo, task.task_id.clone()); + true + } + } + } + + async fn mark_environment_checking(&self) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let checked_at = Some(now_ms()); + state.environment.revision = state.environment.revision.saturating_add(1); + state.environment.status = LoopxEnvironmentStatus::Checking; + state.environment.checked_at = checked_at; + state.environment.core.sidecar = checking_environment_fact(checked_at); + state.environment.core.git_worktree = checking_environment_fact(checked_at); + state.environment.core.agent_model = checking_environment_fact(checked_at); + state.environment.optional.github_auth = checking_environment_fact(checked_at); + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + source: LoopxEventSource::System, + message: "LoopX environment validation started".to_string(), + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + let event = persisted.events.last().cloned(); + drop(state); + self.store.save(&persisted).await?; + if let Some(event) = event { + let _ = self.event_sender.send(event); + } + Ok(()) + } + + async fn commit_environment( + &self, + handshake: LoopxCliResult, + workspace: LoopxHostResult, + agent: LoopxHostResult, + github_auth: LoopxGithubAuthProbe, + ) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let checked_at = Some(now_ms()); + let (sidecar, python_fallback) = match handshake { + Ok(manifest) => { + let python_fallback = + if manifest.executable.source == LoopxCliSource::PythonFallback { + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Available, + version: Some("Python 3.11+".to_string()), + detail: Some( + "Managed LoopX source runs in isolated Python mode".to_string(), + ), + checked_at, + ..LoopxEnvironmentFact::default() + } + } else { + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Unknown, + detail: Some("Not required by the selected LoopX runtime".to_string()), + checked_at, + ..LoopxEnvironmentFact::default() + } + }; + ( + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Available, + version: Some(manifest.loopx_version), + detail: Some(manifest.executable.identity), + checked_at, + ..LoopxEnvironmentFact::default() + }, + python_fallback, + ) + } + Err(error) + if matches!( + error.kind, + LoopxCliErrorKind::NotFound | LoopxCliErrorKind::VersionMismatch + ) => + { + ( + unavailable_loopx_environment_fact(error.to_string(), checked_at), + LoopxEnvironmentFact::default(), + ) + } + Err(error) => ( + unavailable_environment_fact(error.to_string(), checked_at), + LoopxEnvironmentFact::default(), + ), + }; + let git_worktree = match workspace { + Ok(probe) => LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Available, + version: probe.git_version, + detail: Some(format!("Writable workspace root: {}", probe.workspace_root)), + checked_at, + ..LoopxEnvironmentFact::default() + }, + Err(error) => unavailable_environment_fact(error.to_string(), checked_at), + }; + let agent_model = match agent { + Ok(probe) => LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Available, + version: Some(probe.model_id), + detail: Some("Configured Agent model is enabled for text chat".to_string()), + checked_at, + ..LoopxEnvironmentFact::default() + }, + Err(error) => unavailable_environment_fact(error.to_string(), checked_at), + }; + let github_auth = LoopxEnvironmentFact { + status: github_auth_fact_status(&github_auth), + detail: github_auth.detail, + checked_at, + ..LoopxEnvironmentFact::default() + }; + state.environment.revision = state.environment.revision.saturating_add(1); + state.environment.checked_at = checked_at; + state.environment.core.sidecar = sidecar; + state.environment.core.git_worktree = git_worktree; + state.environment.core.agent_model = agent_model; + state.environment.optional.python_fallback = python_fallback; + state.environment.optional.github_auth = github_auth; + state.environment.status = + derive_environment_status(&state.environment.core, &state.environment.optional); + let status = state.environment.status; + state.revision = state.revision.saturating_add(1); + state.append_event(LoopxEvent { + kind: LoopxEventKind::EnvironmentChanged, + level: if status == LoopxEnvironmentStatus::Blocked { + LoopxEventLevel::Error + } else { + LoopxEventLevel::Info + }, + source: LoopxEventSource::System, + message: format!("LoopX environment validation finished with status {status:?}"), + important: status == LoopxEnvironmentStatus::Blocked, + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + let event = persisted.events.last().cloned(); + drop(state); + self.store.save(&persisted).await?; + if let Some(event) = event { + let _ = self.event_sender.send(event); + } + Ok(()) + } + + async fn record_progress(&self, progress: Vec) -> Result<(), String> { + if progress.is_empty() { + return Ok(()); + } + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let start_cursor = state.cursor; + for item in progress { + if is_normal_process_lifecycle_message(&item.message) { + continue; + } + state.append_event(LoopxEvent { + task_id: item.task_id, + kind: LoopxEventKind::Progress, + source: LoopxEventSource::Sidecar, + message: item.message, + occurred_at: item.occurred_at, + ..LoopxEvent::default() + }); + } + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + self.broadcast_new_events(&persisted, start_cursor); + Ok(()) + } + + async fn bind_workspace( + &self, + task_id: &str, + generation: u64, + workspace: &LoopxWorkspacePrepareResult, + ) -> Result<(), String> { + self.mutate_task(task_id, None, |task, runtime| { + if task.generation != generation { + return; + } + task.workspace_path = Some(workspace.worktree_path.clone()); + task.phase = LoopxPhase::CreatingGoal; + task.revision = task.revision.saturating_add(1); + runtime.registry_path = workspace.registry_path.clone(); + }) + .await + .map(|_| ()) + } + + async fn bind_goal( + &self, + task_id: &str, + generation: u64, + goal: LoopxCliCreateGoalResult, + ) -> Result<(), String> { + self.mutate_task(task_id, None, |task, runtime| { + if task.generation != generation { + return; + } + task.goal_id = Some(goal.goal_id.clone()); + task.goal_state = Some(LoopxCliGoalState::Active); + task.state = LoopxTaskState::Queued; + task.phase = LoopxPhase::InspectingGoal; + task.revision = task.revision.saturating_add(1); + runtime.expected_durable_revision = Some(goal.durable_revision.clone()); + }) + .await + .map(|_| ()) + } + + async fn record_goal_state( + &self, + task: &LoopxTaskSnapshot, + goal_state: LoopxCliGoalState, + ) -> Result<(), String> { + if task.goal_state == Some(goal_state) { + return Ok(()); + } + self.mutate_task(&task.task_id, None, |current, _| { + if current.generation != task.generation { + return; + } + current.goal_state = Some(goal_state); + current.revision = current.revision.saturating_add(1); + }) + .await + .map(|_| ()) + } + + /// Persists the bounded LoopX frontier-todo projection for UI display. + /// The projection is written only when it actually changes so heartbeat + /// polling does not churn the durable revision. + async fn record_current_todo( + &self, + task_id: &str, + generation: u64, + todo: Option, + ) -> Result<(), String> { + self.mutate_task(task_id, None, |current, _| { + if current.generation != generation || current.current_todo == todo { + return; + } + current.current_todo = todo; + current.revision = current.revision.saturating_add(1); + }) + .await + .map(|_| ()) + } + + async fn apply_goal_projection( + &self, + expected: &LoopxTaskSnapshot, + goal: &LoopxCliGoalSnapshot, + ) -> Result<(), String> { + let projection = project_host_task_from_goal(expected.state, expected.phase, goal.state); + let preserve_pending_gate = preserve_unanswered_local_gate(expected, goal); + let pending_gate_id = if preserve_pending_gate { + expected.pending_gate_id.as_deref() + } else { + goal.pending_user_gate + .as_ref() + .map(|gate| gate.gate_id.as_str()) + }; + let pending_gate_message = if preserve_pending_gate { + expected.pending_gate_message.as_deref() + } else { + goal.pending_user_gate + .as_ref() + .map(|gate| gate.message.as_str()) + }; + let pending_gate_action_kind = if preserve_pending_gate { + expected.pending_gate_action_kind.as_deref() + } else { + goal.pending_user_gate + .as_ref() + .and_then(|gate| gate.action_kind.as_deref()) + }; + if expected.goal_state == Some(goal.state) + && expected.state == projection.state + && expected.phase == projection.phase + && expected.pending_gate_id.as_deref() == pending_gate_id + && expected.pending_gate_message.as_deref() == pending_gate_message + && expected.pending_gate_action_kind.as_deref() == pending_gate_action_kind + { + return Ok(()); + } + + let host_state_changed = expected.state != projection.state; + let updated = self + .mutate_task(&expected.task_id, None, |task, runtime| { + if task.generation != expected.generation { + return; + } + let preserve_pending_gate = preserve_unanswered_local_gate(task, goal); + let current = project_host_task_from_goal(task.state, task.phase, goal.state); + task.goal_state = Some(goal.state); + task.state = current.state; + task.phase = current.phase; + if current.state == LoopxTaskState::Completed { + task.current_todo = None; + } + // The authoritative Goal projection is healthy again: a stale + // environment-level error (for example a coordination store + // schema rejection from a cross-build data home) must not keep + // resurfacing on a task that is demonstrably running. + if !matches!( + current.state, + LoopxTaskState::RecoveryRequired | LoopxTaskState::Failed + ) { + task.error = None; + } + if !preserve_pending_gate { + task.pending_gate_id = goal + .pending_user_gate + .as_ref() + .map(|gate| gate.gate_id.clone()); + task.pending_gate_message = goal + .pending_user_gate + .as_ref() + .map(|gate| gate.message.clone()); + task.pending_gate_action_kind = goal + .pending_user_gate + .as_ref() + .and_then(|gate| gate.action_kind.clone()); + } + task.revision = task.revision.saturating_add(1); + runtime.expected_durable_revision = Some(goal.durable_revision.clone()); + if task.state.is_terminal() { + task.current_turn_id = None; + task.current_tool = None; + task.deadline_at = None; + task.retry_at = None; + } + }) + .await?; + + if host_state_changed { + self.append_task_event( + &updated, + LoopxEventKind::SnapshotInvalidated, + "BitFun host task reconciled with authoritative LoopX Goal state", + false, + ) + .await?; + if updated.state == LoopxTaskState::Queued { + self.enqueue_task(updated.task_id.clone(), Duration::ZERO)?; + } + } + Ok(()) + } + + async fn bind_turn( + &self, + task: &LoopxTaskSnapshot, + turn: &LoopxCliBuildTurnResult, + ) -> Result<(), String> { + let generation = task.generation; + self.mutate_task(&task.task_id, None, |task, runtime| { + if task.generation != generation { + return; + } + task.phase = LoopxPhase::StartingAgent; + task.deadline_at = turn.deadline_at; + task.current_turn_id = Some(turn.turn_id.clone()); + task.revision = task.revision.saturating_add(1); + runtime.loopx_turn_id = Some(turn.turn_id.clone()); + runtime.settlement_token = Some(turn.settlement_token.clone()); + runtime.expected_durable_revision = Some(turn.durable_revision.clone()); + }) + .await + .map(|_| ()) + } + + async fn bind_agent_run( + &self, + task: &LoopxTaskSnapshot, + run: LoopxAgentStartResult, + ) -> Result<(), String> { + let updated = self + .mutate_task(&task.task_id, None, |task, runtime| { + task.state = LoopxTaskState::Running; + task.phase = LoopxPhase::AgentRunning; + task.current_turn_id = Some(run.turn_id.clone()); + task.last_output_at = Some(now_ms()); + task.revision = task.revision.saturating_add(1); + runtime.session_id = Some(run.session_id.clone()); + runtime.agent_turn_id = Some(run.turn_id.clone()); + }) + .await?; + self.append_task_event( + &updated, + LoopxEventKind::StateChanged, + "Agent turn started", + false, + ) + .await + } + + async fn transition_task( + &self, + task_id: &str, + generation: u64, + state: LoopxTaskState, + phase: LoopxPhase, + message: &str, + ) -> Result { + let updated = self + .mutate_task(task_id, None, |task, _| { + if task.generation != generation { + return; + } + task.state = state; + task.phase = phase; + if state != LoopxTaskState::WaitingForUser { + task.pending_gate_id = None; + task.pending_gate_message = None; + task.pending_gate_action_kind = None; + } + task.revision = task.revision.saturating_add(1); + }) + .await?; + self.append_task_event( + &updated, + LoopxEventKind::StateChanged, + message, + state == LoopxTaskState::RecoveryRequired, + ) + .await?; + Ok(updated) + } + + async fn transition_action( + &self, + task_id: &str, + state: LoopxTaskState, + phase: LoopxPhase, + request_id: &str, + ) -> Result { + let updated = self + .mutate_task(task_id, Some(request_id), |task, _| { + task.state = state; + task.phase = phase; + task.recovery_reason = if state == LoopxTaskState::RecoveryRequired { + Some("manual_restore".to_string()) + } else { + None + }; + if state != LoopxTaskState::WaitingForUser { + task.pending_gate_id = None; + task.pending_gate_message = None; + task.pending_gate_action_kind = None; + } + task.revision = task.revision.saturating_add(1); + }) + .await?; + Ok(LoopxActionResponse { + current_revision: updated.revision, + task: Some(updated), + ..LoopxActionResponse::default() + }) + } + + async fn update_task_phase( + &self, + task_id: &str, + generation: u64, + phase: LoopxPhase, + message: &str, + ) -> Result<(), String> { + let updated = self + .mutate_task(task_id, None, |task, _| { + if task.generation != generation { + return; + } + task.phase = phase; + task.revision = task.revision.saturating_add(1); + }) + .await?; + self.append_task_event(&updated, LoopxEventKind::PhaseChanged, message, false) + .await + } + + async fn fail_task(self: &Arc, task_id: &str, error: String) -> Result<(), String> { + log::error!("LoopX task failed: task_id={} error={}", task_id, error); + let updated = self + .mutate_task(task_id, None, |task, _| { + let workspace_was_never_prepared = task.workspace_path.is_none() + && task.goal_id.is_none() + && task.current_turn_id.is_none(); + task.state = if workspace_was_never_prepared { + LoopxTaskState::Failed + } else { + LoopxTaskState::RecoveryRequired + }; + task.phase = if workspace_was_never_prepared { + LoopxPhase::Finished + } else { + LoopxPhase::Recovering + }; + task.pending_gate_id = None; + task.pending_gate_message = None; + task.pending_gate_action_kind = None; + task.error = Some(error.clone()); + task.recovery_reason = Some("execution_failure".to_string()); + task.deadline_at = None; + task.revision = task.revision.saturating_add(1); + }) + .await?; + self.append_task_event(&updated, LoopxEventKind::StateChanged, &error, true) + .await?; + self.schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&updated.task_id), + ) + .await; + Ok(()) + } + + /// Parks a task whose Goal is still Active after its plan ran dry. This + /// is the mandated recovery for the `RunNow + 0 open todo` frontier + /// contradiction: the host never fabricates a terminal Goal transition. + /// Unlike [`Self::fail_task`] it records an explicit, stable reason + /// (`plan_exhausted`) so the recovery card can offer targeted guidance + /// instead of a generic execution failure, and it still yields the + /// repository slot to queued sibling issues. + async fn park_plan_exhausted( + self: &Arc, + task: &LoopxTaskSnapshot, + goal_id: &str, + ) -> Result<(), String> { + log::warn!( + "LoopX plan exhausted, parking for owner decision: task_id={} goal={}", + task.task_id, + goal_id + ); + let message = LOOPX_PLAN_EXHAUSTED_MESSAGE; + let updated = self + .mutate_task(&task.task_id, None, |current, _| { + current.state = LoopxTaskState::RecoveryRequired; + current.phase = LoopxPhase::Recovering; + current.recovery_reason = Some(LOOPX_PLAN_EXHAUSTED_REASON.to_string()); + current.error = Some(message.to_string()); + current.pending_gate_id = None; + current.pending_gate_message = None; + current.pending_gate_action_kind = None; + current.deadline_at = None; + current.revision = current.revision.saturating_add(1); + }) + .await?; + self.append_task_event(&updated, LoopxEventKind::StateChanged, message, true) + .await?; + self.schedule_next_for_repository( + &updated.identity.item.repository.canonical_id(), + Some(&updated.task_id), + ) + .await; + Ok(()) + } + + /// Best-effort cleanup of the task's on-disk worktree. Called only from + /// the explicit Archive action; failure is recorded as an event, never + /// fatal to the transition. + async fn dispose_task_workspace(self: &Arc, task: &LoopxTaskSnapshot) { + if task.workspace_path.is_none() { + return; + } + let progress = BufferedProgress::default(); + let result = self + .workspace + .dispose(LoopxWorkspaceDisposeRequest { + operation_id: format!("dispose-{}", uuid::Uuid::new_v4()), + task_id: task.task_id.clone(), + item: task.identity.item.clone(), + }) + .await + .map_err(|error| error.message.clone()); + self.record_progress(progress.take()).await.ok(); + let important = result.is_err(); + let message = match result { + Ok(disposed) if disposed.removed => { + "Archived task worktree cleaned up (disk space released)".to_string() + } + Ok(_) => "Archived task had no managed worktree to clean up".to_string(), + Err(error) => { + // Keep the archive transition; surface cleanup failure. + format!("Failed to clean up archived task worktree: {error}") + } + }; + let _ = self + .append_task_event(task, LoopxEventKind::StateChanged, &message, important) + .await; + } + + async fn schedule_next_for_repository( + self: &Arc, + repository_id: &str, + exclude_task_id: Option<&str>, + ) -> bool { + if let Some(owner) = exclude_task_id { + let mut active = self.active_repositories.lock().await; + if active.get(repository_id).map(String::as_str) == Some(owner) { + active.remove(repository_id); + } + } + let next = { + let state = self.state.read().await; + state + .tasks + .iter() + .find(|task| { + // Preparing joins Queued as schedulable: a reserved task + // whose drive never completed would otherwise stall the + // whole repository line once the running slot frees up. + // Re-driving it is safe — reserve_repository bounces the + // task back to Queued when the slot is still taken. + matches!( + task.state, + LoopxTaskState::Queued | LoopxTaskState::Preparing + ) && task.identity.item.repository.canonical_id() == repository_id + && exclude_task_id != Some(task.task_id.as_str()) + }) + .map(|task| task.task_id.clone()) + }; + if let Some(task_id) = next { + self.enqueue_task(task_id, Duration::ZERO).is_ok() + } else { + false + } + } + + async fn enqueue_ready_tasks_after_load(&self) { + if self.load_error.read().await.is_some() { + return; + } + let task_ids = { + let state = self.state.read().await; + state + .tasks + .iter() + .filter(|task| task.state == LoopxTaskState::Queued) + .map(|task| task.task_id.clone()) + .collect::>() + }; + for task_id in task_ids { + let _ = self.enqueue_task(task_id, Duration::ZERO); + } + } + + fn enqueue_task(&self, task_id: String, delay: Duration) -> Result<(), String> { + if !delay.is_zero() { + let sender = self.task_sender.clone(); + tokio::spawn(async move { + tokio::time::sleep(delay).await; + let _ = sender.send(ScheduledTask { task_id }); + }); + return Ok(()); + } + self.task_sender + .send(ScheduledTask { task_id }) + .map_err(|_| "LoopX controller task runner is unavailable".to_string()) + } + + async fn reserve_scheduled_task(&self, task_id: &str) -> bool { + let mut active = self.active_tasks.lock().await; + match active.get_mut(task_id) { + Some(pending) => { + *pending = true; + false + } + None => { + active.insert(task_id.to_string(), false); + true + } + } + } + + async fn release_scheduled_task(&self, task_id: &str) -> bool { + self.active_tasks + .lock() + .await + .remove(task_id) + .unwrap_or(false) + } + + async fn suppress_pending_task_rerun(&self, task_id: &str) { + if let Some(pending) = self.active_tasks.lock().await.get_mut(task_id) { + *pending = false; + } + } + + async fn mutate_task( + &self, + task_id: &str, + request_id: Option<&str>, + update: impl FnOnce(&mut LoopxTaskSnapshot, &mut LoopxTaskRuntimeRecord), + ) -> Result { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + let task_index = state + .tasks + .iter() + .position(|task| task.task_id == task_id) + .ok_or_else(|| "LoopX task not found".to_string())?; + let mut runtime = state.runtime.remove(task_id).unwrap_or_default(); + update(&mut state.tasks[task_index], &mut runtime); + state.tasks[task_index].updated_at = now_ms(); + let updated = state.tasks[task_index].clone(); + state.runtime.insert(task_id.to_string(), runtime); + state.revision = state.revision.saturating_add(1); + if let Some(request_id) = request_id { + state.record_processed_request(request_id.to_string()); + } + let persisted = state.clone(); + drop(state); + self.store.save(&persisted).await?; + Ok(updated) + } + + async fn append_task_event( + &self, + task: &LoopxTaskSnapshot, + kind: LoopxEventKind, + message: &str, + important: bool, + ) -> Result<(), String> { + self.append_task_event_with_details(task, kind, message, important, BTreeMap::new()) + .await + } + + async fn append_task_event_with_details( + &self, + task: &LoopxTaskSnapshot, + kind: LoopxEventKind, + message: &str, + important: bool, + details: BTreeMap, + ) -> Result<(), String> { + let _mutation = self.mutation_lock.lock().await; + let mut state = self.state.write().await; + state.append_event(LoopxEvent { + task_id: Some(task.task_id.clone()), + generation: Some(task.generation), + revision: Some(task.revision), + kind, + level: if kind == LoopxEventKind::ApprovalRequired { + LoopxEventLevel::Warning + } else if important { + LoopxEventLevel::Error + } else { + LoopxEventLevel::Info + }, + source: LoopxEventSource::Controller, + phase: Some(task.phase), + message: message.to_string(), + important, + details, + occurred_at: now_ms(), + ..LoopxEvent::default() + }); + let persisted = state.clone(); + let event = persisted.events.last().cloned(); + drop(state); + self.store.save(&persisted).await?; + if let Some(event) = event { + let _ = self.event_sender.send(event); + } + Ok(()) + } + + async fn task(&self, task_id: &str) -> Result { + self.state + .read() + .await + .tasks + .iter() + .find(|task| task.task_id == task_id) + .cloned() + .ok_or_else(|| "LoopX task not found".to_string()) + } + + async fn runtime(&self, task_id: &str) -> LoopxTaskRuntimeRecord { + self.state + .read() + .await + .runtime + .get(task_id) + .cloned() + .unwrap_or_default() + } + + async fn ensure_writable(&self) -> Result<(), String> { + match self.load_error.read().await.clone() { + Some(error) => Err(error), + None => Ok(()), + } + } + + async fn persist_current(&self) -> Result<(), String> { + let state = self.state.read().await.clone(); + self.store.save(&state).await + } + + fn broadcast_new_events(&self, state: &LoopxPersistedState, after_cursor: u64) { + for event in state + .events + .iter() + .filter(|event| event.cursor > after_cursor) + { + let _ = self.event_sender.send(event.clone()); + } + } + + fn goal_context( + &self, + task: &LoopxTaskSnapshot, + runtime: &LoopxTaskRuntimeRecord, + ) -> LoopxCliGoalContext { + LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: runtime.operation_id.clone(), + deadline_at: task.deadline_at, + }, + task_id: task.task_id.clone(), + generation: task.generation, + worktree_path: task.workspace_path.clone().unwrap_or_default(), + registry_path: runtime.registry_path.clone(), + available_capabilities: self.agent_capabilities.clone(), + } + } +} + +fn is_normal_process_lifecycle_message(message: &str) -> bool { + matches!( + message, + "Starting LoopX process" | "LoopX process exited successfully" + ) +} + +fn task_has_bound_goal(task: &LoopxTaskSnapshot) -> bool { + task.goal_id + .as_deref() + .is_some_and(|goal_id| !goal_id.trim().is_empty()) +} + +/// The bound goal's workspace directory is gone from disk; the task must +/// re-run the prepare + connect flow instead of spawning CLI processes +/// against an invalid working directory. +fn bound_workspace_missing(task: &LoopxTaskSnapshot) -> bool { + task.workspace_path + .as_deref() + .map(|path| !std::path::Path::new(path).exists()) + .unwrap_or(false) +} + +fn is_repository_recovery_candidate(task: &LoopxTaskSnapshot, repository_id: &str) -> bool { + decide_repository_recovery_candidate(task, repository_id) +} +/// Whether `apply_settlement` should re-inspect the durable Goal after a +/// settlement before deciding the task's next state. +/// +/// `Settled`/`AlreadySettled` keep today's behavior (any non-failed agent +/// status). `RetryRequired` from a COMPLETED turn is the pinned CLI's +/// false-negative settlement: the durable writeback matched but the quota +/// spend receipt is missing (observed live 2026-09-05 on dsh-desktop#830, +/// whose final onboarding turn closed the goal vision but skipped +/// `quota spend-slot`), so the authoritative Goal projection — not the +/// missing bookkeeping receipt — decides what happens next, and a +/// corrective turn is not an option because a terminal frontier refuses +/// the quota guard. `RetryRequired` from a cancelled or interrupted turn +/// keeps the explicit recovery path: the owner interrupted that turn, so +/// the host does not silently re-drive it. A failed agent turn and +/// `NoDurableProgress` (no validated writeback) keep their existing paths. +fn inspects_goal_after_settlement( + agent_status: LoopxAgentTurnStatus, + settlement_status: LoopxCliSettlementStatus, +) -> bool { + match settlement_status { + LoopxCliSettlementStatus::Settled | LoopxCliSettlementStatus::AlreadySettled => { + agent_status != LoopxAgentTurnStatus::Failed + } + LoopxCliSettlementStatus::RetryRequired => agent_status == LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress | LoopxCliSettlementStatus::GoalCompleted => { + false + } + } +} + +/// Decides the task state after a turn settlement. When the post-settlement +/// Goal inspection produced a snapshot, the CLI's authoritative projection +/// wins: this covers normal settled turns and the false-negative +/// `RetryRequired` settlement (validated writeback, missing quota receipt; +/// see [`inspects_goal_after_settlement`]). Without a snapshot (inspection +/// failed or not attempted), the settlement status alone decides — missing +/// durable progress or a missing receipt then parks in explicit recovery. +fn task_state_after_settlement( + agent_status: LoopxAgentTurnStatus, + settlement_status: LoopxCliSettlementStatus, + post_settlement_goal: Option<&LoopxCliGoalSnapshot>, +) -> LoopxTaskState { + if agent_status == LoopxAgentTurnStatus::Failed { + return LoopxTaskState::RecoveryRequired; + } + if let Some(goal) = post_settlement_goal { + return match goal.run_decision { + LoopxCliRunDecision::WaitingForUser => LoopxTaskState::WaitingForUser, + LoopxCliRunDecision::Complete => LoopxTaskState::Completed, + LoopxCliRunDecision::Failed => LoopxTaskState::RecoveryRequired, + LoopxCliRunDecision::RunNow | LoopxCliRunDecision::Wait => LoopxTaskState::Queued, + }; + } + match settlement_status { + LoopxCliSettlementStatus::GoalCompleted => LoopxTaskState::Completed, + LoopxCliSettlementStatus::Settled | LoopxCliSettlementStatus::AlreadySettled => { + LoopxTaskState::Queued + } + LoopxCliSettlementStatus::NoDurableProgress | LoopxCliSettlementStatus::RetryRequired => { + LoopxTaskState::RecoveryRequired + } + } +} + +/// Pure witness for the RunNow frontier contradiction described in +/// `drive_turn`: the envelope must itself assert there is nothing to do. +fn run_now_is_frontier_contradiction( + open_todo_count: u32, + waiting_user_todo_count: u32, + has_selected_todo: bool, +) -> bool { + open_todo_count == 0 && waiting_user_todo_count == 0 && !has_selected_todo +} + +/// What the host does with a todo-less `RunNow` frontier. Runtime-data +/// correction (2026-09-05 five-issue run): the pinned CLI v0.5.1 projects +/// `should_run=true` with an autonomous replan obligation when the plan runs +/// dry, and expects the host to drive one bounded replan turn bound to that +/// obligation — parking there stranded every task of that run. Only a +/// todo-less frontier WITHOUT an open obligation is a contract contradiction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TodolessRunNowFrontier { + /// Drive one autonomous replan turn bound to the open obligation: the + /// agent writes back a successor todo, a typed terminal outcome, or a + /// concrete blocker; settlement validates by the `autonomous_replan` + /// effect id and the CLI's replan stall threshold bounds no-op cycles. + DriveReplanTurn, + /// No actionable frontier remains: park with `plan_exhausted` for an + /// owner decision. + Park, +} + +fn todoless_run_now_frontier(pending_replan_obligation_id: Option<&str>) -> TodolessRunNowFrontier { + if pending_replan_obligation_id.is_some_and(|value| !value.trim().is_empty()) { + TodolessRunNowFrontier::DriveReplanTurn + } else { + TodolessRunNowFrontier::Park + } +} + +/// Read-only LoopX user gates: public issue/comment metadata access is +/// agent work, not an owner decision. New read-only gate kinds must be +/// added here deliberately; external-write gates always stay interactive. +fn is_read_only_user_gate(action_kind: Option<&str>) -> bool { + let Some(kind) = action_kind.map(str::trim) else { + return false; + }; + kind == "approve_github_issue_body_or_comment_read" + || (kind.starts_with("approve_") && kind.ends_with("_read")) +} + +/// Reuse-existing-PR merge gates. LoopX may project these without a typed +/// action kind, so the envelope message carries the semantics. +fn is_reuse_merge_user_gate(action_kind: Option<&str>, message: &str) -> bool { + let kind = action_kind + .map(str::trim) + .unwrap_or_default() + .to_ascii_lowercase(); + let message_lower = message.to_ascii_lowercase(); + kind.contains("merge") + || kind.contains("reuse") + || message_lower.contains("merge pr #") + || message_lower.contains("reuse existing pr") +} + +fn reuse_merge_pr_label(message: &str) -> String { + let lower = message.to_ascii_lowercase(); + let index = match lower.find("pr #") { + Some(index) => index + 3, + None => return "the referenced PR".to_string(), + }; + let digits: String = message[index..] + .chars() + .take_while(char::is_ascii_digit) + .collect(); + if digits.is_empty() { + "the referenced PR".to_string() + } else { + format!("PR #{digits}") + } +} + +fn phase_after_settlement(state: LoopxTaskState) -> LoopxPhase { + match state { + LoopxTaskState::Completed => LoopxPhase::Finished, + LoopxTaskState::RecoveryRequired => LoopxPhase::Recovering, + LoopxTaskState::WaitingForUser => LoopxPhase::WaitingForApproval, + _ => LoopxPhase::Queued, + } +} + +fn should_requeue_after_settlement(final_state: LoopxTaskState, yielded_repository: bool) -> bool { + final_state == LoopxTaskState::Queued && !yielded_repository +} + +/// Depth-first repository lane: after a cleanly settled segment, the same task +/// keeps the slot and continues while its Goal is still runnable. Yield to the +/// next queued issue only when the Goal actually paused (user gate, cadence +/// wait, terminal, recovery) or the post-settlement inspection failed to +/// project a decision — a re-drive re-inspects and parks at the gate, so +/// treating an unknown decision as runnable is self-correcting. A monitor- +/// class successor always yields: it waits on an external event the agent +/// cannot advance, so holding the slot would starve sibling issues while the +/// drive-time compatibility cadence spaces the re-checks. +fn sticky_continue_after_settlement( + final_state: LoopxTaskState, + post_settlement_run_decision: Option, + post_settlement_selected_action: Option<&str>, +) -> bool { + if final_state != LoopxTaskState::Queued { + return false; + } + if post_settlement_selected_action.is_some_and(is_loopx_monitor_action) { + return false; + } + match post_settlement_run_decision { + None => true, + Some(decision) => matches!(decision, LoopxCliRunDecision::RunNow), + } +} + +/// v0.5.1 compatibility cadence for monitor-class re-checks: when the goal's +/// last durable settlement happened less than [`MONITOR_COMPAT_INTERVAL_MS`] +/// ago, hold the re-check back for the remaining interval. `None` means run +/// now (no settlement anchor yet — e.g. a resumed or fresh goal — or the +/// interval already elapsed). The anchor is durable settlement evidence, not +/// a host-side convergence counter. +fn monitor_recheck_hold_ms(settled_at: Option, now: i64) -> Option { + let settled_at = settled_at?; + let elapsed = now.saturating_sub(settled_at); + if elapsed < MONITOR_COMPAT_INTERVAL_MS as i64 { + Some((MONITOR_COMPAT_INTERVAL_MS as i64 - elapsed).max(0) as u64) + } else { + None + } +} + +fn goal_id_for(identity: &LoopxTaskIdentity) -> String { + let item = &identity.item; + let kind = match item.kind { + LoopxItemKind::Issue => "issue", + LoopxItemKind::PullRequest => "pr", + }; + let suffix = if identity.attempt > 1 { + format!("-{}", identity.attempt) + } else { + String::new() + }; + format!( + "bfx-{}-{}-{kind}-{}{}", + item.repository.owner, item.repository.repository, item.number, suffix + ) +} + +fn existing_outcomes( + state: &LoopxPersistedState, + selected: &std::collections::BTreeSet, +) -> Vec { + selected + .iter() + .map(|item| { + let task = state + .tasks + .iter() + .filter(|task| &task.identity.item == item) + .max_by_key(|task| task.identity.attempt); + LoopxCreateTaskOutcome { + item: item.clone(), + kind: LoopxCreateTaskOutcomeKind::OpenedExisting, + task_id: task.map(|task| task.task_id.clone()), + attempt: task.map(|task| task.identity.attempt), + ..LoopxCreateTaskOutcome::default() + } + }) + .collect() +} + +fn prune_intake_previews(previews: &mut HashMap, now: i64) { + previews.retain(|_, preview| intake_preview_is_fresh(preview, now)); + if previews.len() <= MAX_INTAKE_PREVIEWS { + return; + } + + let mut by_age = previews + .iter() + .map(|(fingerprint, preview)| (fingerprint.clone(), preview.resolved_at)) + .collect::>(); + by_age.sort_by_key(|(_, resolved_at)| *resolved_at); + let excess = previews.len().saturating_sub(MAX_INTAKE_PREVIEWS); + for (fingerprint, _) in by_age.into_iter().take(excess) { + previews.remove(&fingerprint); + } +} + +fn intake_preview_is_fresh(preview: &LoopxIntakePreview, now: i64) -> bool { + match preview.expires_at { + Some(expires_at) => expires_at > now, + None => false, + } +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +fn bounded_agent_summary(summary: &str) -> String { + let mut chars = summary.chars(); + let bounded = chars + .by_ref() + .take(MAX_AGENT_SUMMARY_CHARS) + .collect::(); + if chars.next().is_some() { + format!("{bounded}\n\n[Summary truncated by LoopX host]") + } else { + bounded + } +} + +fn github_auth_fact_status(probe: &LoopxGithubAuthProbe) -> LoopxEnvironmentFactStatus { + if probe.authenticated { + LoopxEnvironmentFactStatus::Available + } else if probe.rate_limit_remaining.is_some() { + LoopxEnvironmentFactStatus::Degraded + } else { + LoopxEnvironmentFactStatus::Unavailable + } +} + +fn checking_environment_fact(checked_at: Option) -> LoopxEnvironmentFact { + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Checking, + checked_at, + ..LoopxEnvironmentFact::default() + } +} + +fn unavailable_environment_fact( + detail: impl Into, + checked_at: Option, +) -> LoopxEnvironmentFact { + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Unavailable, + detail: Some(detail.into()), + checked_at, + ..LoopxEnvironmentFact::default() + } +} + +fn unavailable_loopx_environment_fact( + detail: impl Into, + checked_at: Option, +) -> LoopxEnvironmentFact { + LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Unavailable, + detail: Some(detail.into()), + remediation: Some( + "Download the pinned LoopX source from GitHub into BitFun-managed storage".to_string(), + ), + remediation_action: LoopxEnvironmentRemediationAction::InstallLoopx, + checked_at, + ..LoopxEnvironmentFact::default() + } +} + +/// Reconciliation may replace a local gate with a durable gate projection, but +/// it must never infer approval from an active Goal. Only an explicit gate +/// answer transitions the host task away from WaitingForUser. +fn preserve_unanswered_local_gate(task: &LoopxTaskSnapshot, goal: &LoopxCliGoalSnapshot) -> bool { + task.state == LoopxTaskState::WaitingForUser + && task.pending_gate_id.is_some() + && goal.pending_user_gate.is_none() + && !matches!( + goal.state, + LoopxCliGoalState::Completed | LoopxCliGoalState::Failed | LoopxCliGoalState::Archived + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_now_with_a_selected_todo_is_not_a_frontier_contradiction() { + // Regression: the pinned v0.5.1 outer-controller turn plan can report + // open_count = 0 while `action.selected_todo` still names an open, + // agent-claimed todo (observed on the huangruiteng/loopx issue-3859 + // goal). The contradiction witness is the envelope's action + // projection, not the scalar counter. + assert!(!run_now_is_frontier_contradiction(0, 0, true)); + assert!(run_now_is_frontier_contradiction(0, 0, false)); + assert!(!run_now_is_frontier_contradiction(2, 0, false)); + assert!(!run_now_is_frontier_contradiction(0, 1, false)); + } + + #[test] + fn todoless_run_now_frontier_drives_a_replan_turn_only_with_an_obligation() { + // Runtime-data correction (2026-09-05 five-issue run): the pinned CLI + // projects the plan-exhausted frontier as RunNow + replan obligation, + // expecting the host to drive one replan turn. Parking there stranded + // all five tasks in recovery before any goal could close. + assert_eq!( + todoless_run_now_frontier(Some("replan-d4066f99c1ea4b11")), + TodolessRunNowFrontier::DriveReplanTurn, + ); + // A todo-less RunNow frontier without an obligation is the real + // contract contradiction: park for an owner decision. + assert_eq!( + todoless_run_now_frontier(None), + TodolessRunNowFrontier::Park, + ); + assert_eq!( + todoless_run_now_frontier(Some("")), + TodolessRunNowFrontier::Park, + ); + assert_eq!( + todoless_run_now_frontier(Some(" ")), + TodolessRunNowFrontier::Park, + ); + } + + #[test] + fn retry_required_from_a_completed_turn_recovers_from_the_goal_projection() { + // The false-negative settlement (observed live on dsh-desktop#830): + // a completed turn validated its durable writeback but skipped the + // quota spend. The Goal projection — not the missing receipt — must + // decide the next state, because a terminal frontier refuses the + // quota guard and no corrective turn can run. + assert!(inspects_goal_after_settlement( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::RetryRequired, + )); + // Cancelled/interrupted turns keep the explicit recovery path: the + // owner interrupted that turn, so the host must not silently + // re-drive it from the projection. + assert!(!inspects_goal_after_settlement( + LoopxAgentTurnStatus::Cancelled, + LoopxCliSettlementStatus::RetryRequired, + )); + assert!(!inspects_goal_after_settlement( + LoopxAgentTurnStatus::Interrupted, + LoopxCliSettlementStatus::RetryRequired, + )); + // Settled turns keep today's projection-driven handling for any + // non-failed agent status; failed turns and missing writebacks keep + // their existing explicit paths. + assert!(inspects_goal_after_settlement( + LoopxAgentTurnStatus::Cancelled, + LoopxCliSettlementStatus::Settled, + )); + assert!(!inspects_goal_after_settlement( + LoopxAgentTurnStatus::Failed, + LoopxCliSettlementStatus::Settled, + )); + assert!(!inspects_goal_after_settlement( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::NoDurableProgress, + )); + } + + #[test] + fn goal_ids_are_per_item_and_attempt() { + let identity = LoopxTaskIdentity { + item: LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }, + attempt: 2, + ..Default::default() + }; + assert_eq!(goal_id_for(&identity), "bfx-owner-repo-issue-42-2"); + } + + #[test] + fn repository_recovery_includes_all_resumable_tasks() { + let repository = LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }; + let task = |state| LoopxTaskSnapshot { + identity: LoopxTaskIdentity { + item: LoopxIssueKey { + repository: repository.clone(), + kind: LoopxItemKind::Issue, + number: 42, + }, + ..LoopxTaskIdentity::default() + }, + state, + ..LoopxTaskSnapshot::default() + }; + let repository_id = repository.canonical_id(); + + assert!(is_repository_recovery_candidate( + &task(LoopxTaskState::RecoveryRequired), + &repository_id, + )); + assert!(is_repository_recovery_candidate( + &task(LoopxTaskState::Failed), + &repository_id, + )); + assert!(is_repository_recovery_candidate( + &task(LoopxTaskState::Stopped), + &repository_id, + )); + } + + #[test] + fn agent_summary_projection_is_bounded_on_character_boundaries() { + let summary = "界".repeat(MAX_AGENT_SUMMARY_CHARS + 1); + let bounded = bounded_agent_summary(&summary); + + assert!(bounded.ends_with("[Summary truncated by LoopX host]")); + assert_eq!(bounded.matches('界').count(), MAX_AGENT_SUMMARY_CHARS); + } + + #[test] + fn settled_task_does_not_self_requeue_after_yielding_repository() { + assert!(!should_requeue_after_settlement( + LoopxTaskState::Queued, + true, + )); + assert!(should_requeue_after_settlement( + LoopxTaskState::Queued, + false, + )); + assert!(!should_requeue_after_settlement( + LoopxTaskState::Completed, + false, + )); + } + + #[test] + fn depth_first_sticky_continues_only_for_runnable_goals() { + // Cleanly settled + still runnable: keep the slot, continue deep. + assert!(sticky_continue_after_settlement( + LoopxTaskState::Queued, + Some(LoopxCliRunDecision::RunNow), + Some("issue_fix_collect_candidate_evidence"), + )); + // Unknown post-settlement decision (inspection failed): continue — the + // re-drive re-inspects and parks at a gate, so this is self-correcting. + assert!(sticky_continue_after_settlement( + LoopxTaskState::Queued, + None, + None, + )); + // Cadence wait: yield the slot to the next queued issue. + assert!(!sticky_continue_after_settlement( + LoopxTaskState::Queued, + Some(LoopxCliRunDecision::Wait), + None, + )); + assert!(!sticky_continue_after_settlement( + LoopxTaskState::Queued, + Some(LoopxCliRunDecision::WaitingForUser), + None, + )); + // Terminal or parked states always yield. + assert!(!sticky_continue_after_settlement( + LoopxTaskState::Completed, + Some(LoopxCliRunDecision::RunNow), + None, + )); + assert!(!sticky_continue_after_settlement( + LoopxTaskState::RecoveryRequired, + Some(LoopxCliRunDecision::RunNow), + None, + )); + assert!(!sticky_continue_after_settlement( + LoopxTaskState::WaitingForUser, + None, + None, + )); + } + + #[test] + fn depth_first_sticky_yields_monitor_successors_even_when_runnable() { + // v0.5.1 projects a freshly created successor tracking todo RunNow + // immediately; the sticky lane must not let it hold the repository + // slot. It yields and the drive-time compatibility cadence spaces the + // re-checks. + assert!(!sticky_continue_after_settlement( + LoopxTaskState::Queued, + Some(LoopxCliRunDecision::RunNow), + Some("issue_fix_track_pr_merge_readiness"), + )); + assert!(!sticky_continue_after_settlement( + LoopxTaskState::Queued, + None, + Some("issue_fix_pr_state_open_monitor"), + )); + // Real work successors still keep the slot and continue deep. + assert!(sticky_continue_after_settlement( + LoopxTaskState::Queued, + Some(LoopxCliRunDecision::RunNow), + Some("issue_fix_implementation"), + )); + } + + #[test] + fn monitor_recheck_hold_anchors_on_last_settlement() { + let now = 10_000_000_i64; + // No settlement anchor (fresh or resumed goal): run now. + assert_eq!(monitor_recheck_hold_ms(None, now), None); + // Settled 3 seconds ago: hold back the remaining interval. + assert_eq!( + monitor_recheck_hold_ms(Some(now - 3_000), now), + Some(MONITOR_COMPAT_INTERVAL_MS - 3_000) + ); + // Exactly one interval old (or older): run now. + assert_eq!( + monitor_recheck_hold_ms(Some(now - MONITOR_COMPAT_INTERVAL_MS as i64), now), + None + ); + assert_eq!( + monitor_recheck_hold_ms(Some(now - 60 * MONITOR_COMPAT_INTERVAL_MS as i64), now), + None + ); + // Clock skew (settlement timestamp in the future): hold the full + // interval instead of spinning. + assert_eq!( + monitor_recheck_hold_ms(Some(now + 5_000), now), + Some(MONITOR_COMPAT_INTERVAL_MS) + ); + } + + #[test] + fn post_settlement_gate_is_projected_before_requeue() { + let goal = LoopxCliGoalSnapshot { + state: LoopxCliGoalState::WaitingForUser, + run_decision: LoopxCliRunDecision::WaitingForUser, + pending_user_gate: Some(LoopxCliUserGate { + gate_id: "todo-owner".to_string(), + message: "Owner decision required".to_string(), + ..LoopxCliUserGate::default() + }), + ..LoopxCliGoalSnapshot::default() + }; + + let state = task_state_after_settlement( + LoopxAgentTurnStatus::Completed, + LoopxCliSettlementStatus::Settled, + Some(&goal), + ); + assert_eq!(state, LoopxTaskState::WaitingForUser); + assert_eq!( + phase_after_settlement(state), + LoopxPhase::WaitingForApproval + ); + assert!(!should_requeue_after_settlement(state, false)); + } + + #[test] + fn successful_process_lifecycle_messages_are_not_persisted_as_task_events() { + assert!(is_normal_process_lifecycle_message( + "Starting LoopX process" + )); + assert!(is_normal_process_lifecycle_message( + "LoopX process exited successfully" + )); + assert!(!is_normal_process_lifecycle_message( + "LoopX process exited with an error" + )); + assert!(!is_normal_process_lifecycle_message( + "Building a fresh LoopX custom-runner turn contract" + )); + } + + #[test] + fn resumed_tasks_with_a_goal_skip_intake_and_goal_creation() { + assert!(task_has_bound_goal(&LoopxTaskSnapshot { + goal_id: Some("goal-42".to_string()), + ..LoopxTaskSnapshot::default() + })); + assert!(!task_has_bound_goal(&LoopxTaskSnapshot::default())); + assert!(!task_has_bound_goal(&LoopxTaskSnapshot { + goal_id: Some(" ".to_string()), + ..LoopxTaskSnapshot::default() + })); + } + + #[test] + fn reconciliation_cannot_clear_an_unanswered_local_gate() { + let task = LoopxTaskSnapshot { + state: LoopxTaskState::WaitingForUser, + phase: LoopxPhase::WaitingForApproval, + pending_gate_id: Some("todo_owner_review".to_string()), + ..LoopxTaskSnapshot::default() + }; + let active_goal = LoopxCliGoalSnapshot { + state: LoopxCliGoalState::Active, + ..LoopxCliGoalSnapshot::default() + }; + assert!(preserve_unanswered_local_gate(&task, &active_goal)); + + let answered_task = LoopxTaskSnapshot { + state: LoopxTaskState::Queued, + ..task.clone() + }; + assert!(!preserve_unanswered_local_gate( + &answered_task, + &active_goal, + )); + + let completed_goal = LoopxCliGoalSnapshot { + state: LoopxCliGoalState::Completed, + ..active_goal + }; + assert!(!preserve_unanswered_local_gate(&task, &completed_goal)); + } +} diff --git a/src/crates/assembly/core/src/miniapp/loopx/mod.rs b/src/crates/assembly/core/src/miniapp/loopx/mod.rs new file mode 100644 index 0000000000..7ba2797640 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/mod.rs @@ -0,0 +1,10 @@ +mod agent_adapter; +mod controller; +mod store; +mod subscriber; +mod tool_activity; + +pub use agent_adapter::CoreLoopxAgentPort; +pub use controller::LoopxController; +pub use store::{LoopxPersistedState, LoopxStateStore, LoopxTaskRuntimeRecord}; +pub use subscriber::LoopxEventSubscriber; diff --git a/src/crates/assembly/core/src/miniapp/loopx/store.rs b/src/crates/assembly/core/src/miniapp/loopx/store.rs new file mode 100644 index 0000000000..3269b88be9 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/store.rs @@ -0,0 +1,333 @@ +use openbitfun_product_domains::miniapp::loopx::{ + task_state_after_restart, LoopxEnvironmentSnapshot, LoopxEvent, LoopxEventKind, + LoopxEventLevel, LoopxEventSource, LoopxEventsPageStatus, LoopxEventsSinceResponse, + LoopxExecutionDomain, LoopxExecutionSupport, LoopxPhase, LoopxSnapshot, LoopxTaskSnapshot, + LoopxTaskState, +}; +use openbitfun_services_core::json_store::JsonFileStore; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +const LOOPX_STATE_SCHEMA_VERSION: u32 = 1; +const MAX_SEMANTIC_EVENTS: usize = 2_000; +const MAX_IDEMPOTENCY_KEYS: usize = 512; +const DEFAULT_EVENT_PAGE_SIZE: usize = 200; +const MAX_EVENT_PAGE_SIZE: usize = 1_000; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxPersistedState { + pub schema_version: u32, + pub stream_id: String, + pub cursor: u64, + pub revision: u64, + pub environment: LoopxEnvironmentSnapshot, + /// Durable BitFun host jobs and the last read-only LoopX Goal projection. + /// LoopX registry state remains authoritative for Goal lifecycle facts. + pub tasks: Vec, + pub runtime: BTreeMap, + pub events: Vec, + pub processed_request_ids: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxTaskRuntimeRecord { + pub operation_id: String, + pub registry_path: String, + pub session_id: Option, + pub agent_turn_id: Option, + pub loopx_turn_id: Option, + pub settlement_token: Option, + pub expected_durable_revision: Option, + /// Last attempt time of the passive UI-attach Goal reconciliation. Tracked + /// separately from `updated_at` because the reconcile's own progress + /// events must not restart its throttle window. + pub last_goal_reconcile_at_ms: Option, + /// One-shot flag: after a NoDurableProgress settlement the host schedules + /// exactly one corrective turn (with a host note) before parking the task + /// for interactive recovery. + pub durable_compensation_pending: bool, + /// One-shot host note appended to the next agent instruction (used by the + /// durable-writeback compensation turn). + pub pending_host_note: Option, +} + +impl Default for LoopxPersistedState { + fn default() -> Self { + Self::new(0) + } +} + +impl LoopxPersistedState { + pub fn new(_now_ms: i64) -> Self { + Self { + schema_version: LOOPX_STATE_SCHEMA_VERSION, + stream_id: uuid::Uuid::new_v4().to_string(), + cursor: 0, + revision: 0, + environment: LoopxEnvironmentSnapshot::default(), + tasks: Vec::new(), + runtime: BTreeMap::new(), + events: Vec::new(), + processed_request_ids: Vec::new(), + } + } + + pub fn snapshot( + &self, + execution_domain: LoopxExecutionDomain, + execution_support: LoopxExecutionSupport, + unsupported_reason: Option, + now_ms: i64, + ) -> LoopxSnapshot { + LoopxSnapshot { + schema_version: self.schema_version, + stream_id: self.stream_id.clone(), + cursor: self.cursor, + revision: self.revision, + execution_domain, + execution_support, + unsupported_reason, + environment: self.environment.clone(), + tasks: self.tasks.clone(), + generated_at: now_ms, + } + } + + pub fn apply_restart_policy(&mut self, now_ms: i64) -> bool { + let mut changed = false; + let mut recovery_required = 0usize; + let mut requeued = 0usize; + for task in &mut self.tasks { + let restarted = task_state_after_restart(task.state); + if restarted == task.state { + continue; + } + task.state = restarted; + task.phase = if restarted == LoopxTaskState::RecoveryRequired { + recovery_required = recovery_required.saturating_add(1); + task.recovery_reason = Some("host_restart".to_string()); + LoopxPhase::Recovering + } else if restarted == LoopxTaskState::Queued { + requeued = requeued.saturating_add(1); + LoopxPhase::Queued + } else { + task.phase + }; + task.revision = task.revision.saturating_add(1); + task.updated_at = now_ms; + task.current_tool = None; + task.deadline_at = None; + task.retry_at = None; + changed = true; + } + if changed { + let needs_recovery = recovery_required > 0; + let message = match (needs_recovery, requeued > 0) { + (true, true) => { + "Host restarted; interrupted LoopX tasks require recovery and pending tasks were requeued" + } + (true, false) => "Host restarted; interrupted LoopX tasks require explicit recovery", + (false, true) => "Host restarted; pending LoopX tasks were requeued", + (false, false) => "Host restarted; LoopX task state was refreshed", + }; + self.revision = self.revision.saturating_add(1); + self.append_event(LoopxEvent { + kind: LoopxEventKind::SnapshotInvalidated, + level: if needs_recovery { + LoopxEventLevel::Warning + } else { + LoopxEventLevel::Info + }, + source: LoopxEventSource::Controller, + phase: Some(if needs_recovery { + LoopxPhase::Recovering + } else { + LoopxPhase::Queued + }), + message: message.to_string(), + important: needs_recovery, + occurred_at: now_ms, + ..LoopxEvent::default() + }); + } + changed + } + + pub fn append_event(&mut self, mut event: LoopxEvent) { + self.cursor = self.cursor.saturating_add(1); + event.stream_id = self.stream_id.clone(); + event.cursor = self.cursor; + self.events.push(event); + if self.events.len() > MAX_SEMANTIC_EVENTS { + let remove = self.events.len() - MAX_SEMANTIC_EVENTS; + self.events.drain(0..remove); + } + } + + pub fn events_since( + &self, + stream_id: &str, + after_cursor: u64, + requested_limit: Option, + ) -> LoopxEventsSinceResponse { + if stream_id != self.stream_id { + return LoopxEventsSinceResponse { + status: LoopxEventsPageStatus::SnapshotRequired, + stream_id: self.stream_id.clone(), + next_cursor: self.cursor, + ..LoopxEventsSinceResponse::default() + }; + } + + let limit = requested_limit + .map(|value| value as usize) + .unwrap_or(DEFAULT_EVENT_PAGE_SIZE) + .clamp(1, MAX_EVENT_PAGE_SIZE); + let mut available = self + .events + .iter() + .filter(|event| event.cursor > after_cursor); + let events = available.by_ref().take(limit).cloned().collect::>(); + let has_more = available.next().is_some(); + let next_cursor = events + .last() + .map(|event| event.cursor) + .unwrap_or(after_cursor.min(self.cursor)); + LoopxEventsSinceResponse { + status: LoopxEventsPageStatus::Current, + stream_id: self.stream_id.clone(), + events, + next_cursor, + has_more, + } + } + + pub fn has_processed_request(&self, request_id: &str) -> bool { + self.processed_request_ids + .iter() + .any(|existing| existing == request_id) + } + + pub fn record_processed_request(&mut self, request_id: String) { + if request_id.is_empty() || self.has_processed_request(&request_id) { + return; + } + self.processed_request_ids.push(request_id); + if self.processed_request_ids.len() > MAX_IDEMPOTENCY_KEYS { + let remove = self.processed_request_ids.len() - MAX_IDEMPOTENCY_KEYS; + self.processed_request_ids.drain(0..remove); + } + } +} + +#[derive(Debug, Clone)] +pub struct LoopxStateStore { + path: PathBuf, + json: JsonFileStore, +} + +impl LoopxStateStore { + pub fn new(path: PathBuf) -> Self { + Self { + path, + json: JsonFileStore, + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub async fn load(&self) -> Result, String> { + self.json + .read_locked_optional(&self.path) + .await + .map_err(|error| format!("Failed to load LoopX task state: {error}")) + } + + pub async fn save(&self, state: &LoopxPersistedState) -> Result<(), String> { + self.json + .write_atomic_strict(&self.path, state) + .await + .map_err(|error| format!("Failed to persist LoopX task state: {error}")) + } + + pub async fn clear(&self) -> Result<(), String> { + match tokio::fs::remove_file(&self.path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("Failed to remove LoopX task state: {error}")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openbitfun_product_domains::miniapp::loopx::{LoopxTaskSnapshot, LoopxTaskState}; + + #[tokio::test] + async fn restart_requeues_pending_work_and_recovers_inflight_work() { + let root = tempfile::tempdir().expect("tempdir"); + let store = LoopxStateStore::new(root.path().join("loopx-state.json")); + let mut state = LoopxPersistedState::new(10); + state.tasks.push(LoopxTaskSnapshot { + task_id: "task-1".to_string(), + state: LoopxTaskState::Running, + phase: LoopxPhase::AgentRunning, + revision: 3, + created_at: 1, + updated_at: 2, + ..LoopxTaskSnapshot::default() + }); + state.tasks.push(LoopxTaskSnapshot { + task_id: "task-2".to_string(), + state: LoopxTaskState::RetryWait, + phase: LoopxPhase::RetryBackoff, + revision: 5, + created_at: 1, + updated_at: 2, + retry_at: Some(30), + ..LoopxTaskSnapshot::default() + }); + assert!(state.apply_restart_policy(20)); + store.save(&state).await.expect("save"); + + let loaded = store.load().await.expect("load").expect("state"); + assert_eq!(loaded.tasks[0].state, LoopxTaskState::RecoveryRequired); + assert_eq!( + loaded.tasks[0].recovery_reason.as_deref(), + Some("host_restart") + ); + assert_eq!(loaded.tasks[0].revision, 4); + assert_eq!(loaded.tasks[1].state, LoopxTaskState::Queued); + assert_eq!(loaded.tasks[1].phase, LoopxPhase::Queued); + assert_eq!(loaded.tasks[1].retry_at, None); + assert_eq!(loaded.tasks[1].recovery_reason, None); + assert_eq!(loaded.tasks[1].revision, 6); + assert_eq!(loaded.events[0].kind, LoopxEventKind::SnapshotInvalidated); + } + + #[test] + fn cursor_gap_and_pagination_are_explicit() { + let mut state = LoopxPersistedState::new(1); + for index in 0..3 { + state.append_event(LoopxEvent { + message: format!("event-{index}"), + ..LoopxEvent::default() + }); + } + + let wrong = state.events_since("old-stream", 0, None); + assert_eq!(wrong.status, LoopxEventsPageStatus::SnapshotRequired); + let first = state.events_since(&state.stream_id, 0, Some(2)); + assert_eq!(first.events.len(), 2); + assert!(first.has_more); + let second = state.events_since(&state.stream_id, first.next_cursor, Some(2)); + assert_eq!(second.events.len(), 1); + assert!(!second.has_more); + } +} diff --git a/src/crates/assembly/core/src/miniapp/loopx/subscriber.rs b/src/crates/assembly/core/src/miniapp/loopx/subscriber.rs new file mode 100644 index 0000000000..64bf5f9bc4 --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/subscriber.rs @@ -0,0 +1,401 @@ +use super::tool_activity::project_tool_activity; +use super::LoopxController; +use crate::agentic::events::{AgenticEvent, EventSubscriber}; +use openbitfun_agent_runtime::event_bus::{EventBusError, EventSubscriberResult}; +use openbitfun_core_types::errors::{AiErrorDetail, ErrorCategory}; +use openbitfun_product_domains::miniapp::loopx::LoopxAgentTurnStatus; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +const ACTIVITY_PERSIST_INTERVAL: Duration = Duration::from_secs(2); +const MAX_FINAL_RESPONSE_CHARS: usize = 16_000; + +pub struct LoopxEventSubscriber { + controller: Arc, + activity: ActivityGate, +} + +impl LoopxEventSubscriber { + pub fn new(controller: Arc) -> Self { + Self { + controller, + activity: ActivityGate::default(), + } + } +} + +#[derive(Default)] +struct ActivityGate(Mutex>); + +#[derive(Default)] +struct TurnActivity { + last_persisted: Option, + stream_events: u64, + suppressed_tool_events: u64, + tool_lifecycle_events: u64, + persisted_checkpoints: u64, + latest_round_id: Option, + latest_attempt_id: Option, + latest_round_text: String, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct ActivitySummary { + stream_events: u64, + suppressed_tool_events: u64, + tool_lifecycle_events: u64, + persisted_checkpoints: u64, + final_response: Option, +} + +impl ActivityGate { + fn start_round(&self, turn_id: &str, round_id: &str) { + let mut turns = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let activity = turns.entry(turn_id.to_string()).or_default(); + if activity.latest_round_id.as_deref() != Some(round_id) { + activity.latest_round_id = Some(round_id.to_string()); + activity.latest_attempt_id = None; + activity.latest_round_text.clear(); + } + } + + fn record_text(&self, turn_id: &str, round_id: &str, attempt_id: Option<&str>, text: &str) { + let mut turns = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let activity = turns.entry(turn_id.to_string()).or_default(); + let round_changed = activity.latest_round_id.as_deref() != Some(round_id); + let attempt_changed = !round_changed + && attempt_id.is_some_and(|incoming| { + activity + .latest_attempt_id + .as_deref() + .is_some_and(|current| current != incoming) + }); + if round_changed || attempt_changed { + activity.latest_round_id = Some(round_id.to_string()); + activity.latest_attempt_id = attempt_id.map(str::to_string); + activity.latest_round_text.clear(); + } else if activity.latest_attempt_id.is_none() { + activity.latest_attempt_id = attempt_id.map(str::to_string); + } + append_bounded_text( + &mut activity.latest_round_text, + text, + MAX_FINAL_RESPONSE_CHARS, + ); + } + + fn record_stream(&self, turn_id: &str, force: bool) -> bool { + self.record_stream_at(turn_id, Instant::now(), force) + } + + fn record_stream_at(&self, turn_id: &str, now: Instant, force: bool) -> bool { + let mut turns = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let activity = turns.entry(turn_id.to_string()).or_default(); + activity.stream_events = activity.stream_events.saturating_add(1); + let should_persist = force + || activity + .last_persisted + .is_none_or(|last| now.duration_since(last) >= ACTIVITY_PERSIST_INTERVAL); + if should_persist { + activity.last_persisted = Some(now); + activity.persisted_checkpoints = activity.persisted_checkpoints.saturating_add(1); + } + should_persist + } + + fn record_suppressed_tool_event(&self, turn_id: &str) -> bool { + let now = Instant::now(); + let mut turns = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let activity = turns.entry(turn_id.to_string()).or_default(); + activity.suppressed_tool_events = activity.suppressed_tool_events.saturating_add(1); + let should_persist = activity + .last_persisted + .is_none_or(|last| now.duration_since(last) >= ACTIVITY_PERSIST_INTERVAL); + if should_persist { + activity.last_persisted = Some(now); + activity.persisted_checkpoints = activity.persisted_checkpoints.saturating_add(1); + } + should_persist + } + + fn record_tool_lifecycle(&self, turn_id: &str) { + let mut turns = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let activity = turns.entry(turn_id.to_string()).or_default(); + activity.last_persisted = Some(Instant::now()); + activity.tool_lifecycle_events = activity.tool_lifecycle_events.saturating_add(1); + activity.persisted_checkpoints = activity.persisted_checkpoints.saturating_add(1); + } + + fn finish(&self, turn_id: &str) -> Option { + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(turn_id) + .map(|activity| ActivitySummary { + stream_events: activity.stream_events, + suppressed_tool_events: activity.suppressed_tool_events, + tool_lifecycle_events: activity.tool_lifecycle_events, + persisted_checkpoints: activity.persisted_checkpoints, + final_response: (!activity.latest_round_text.trim().is_empty()) + .then(|| activity.latest_round_text.trim().to_string()), + }) + } +} + +impl LoopxEventSubscriber { + fn finish_activity(&self, turn_id: &str) -> Option { + let Some(summary) = self.activity.finish(turn_id) else { + return None; + }; + log::info!( + "LoopX Agent event projection summary: turn_id={}, stream_events={}, suppressed_tool_events={}, tool_lifecycle_events={}, persisted_checkpoints={}", + turn_id, + summary.stream_events, + summary.suppressed_tool_events, + summary.tool_lifecycle_events, + summary.persisted_checkpoints + ); + Some(summary) + } +} + +#[async_trait::async_trait] +impl EventSubscriber for LoopxEventSubscriber { + async fn on_event(&self, event: &AgenticEvent) -> EventSubscriberResult { + let result = match event { + AgenticEvent::TextChunk { + turn_id, + round_id, + attempt_id, + text, + .. + } => { + self.activity + .record_text(turn_id, round_id, attempt_id.as_deref(), text); + if self.activity.record_stream(turn_id, false) { + self.controller.handle_agent_activity(turn_id).await + } else { + Ok(()) + } + } + AgenticEvent::ThinkingChunk { turn_id, .. } => { + if self.activity.record_stream(turn_id, false) { + self.controller.handle_agent_activity(turn_id).await + } else { + Ok(()) + } + } + AgenticEvent::ModelRoundStarted { + turn_id, round_id, .. + } => { + self.activity.start_round(turn_id, round_id); + if self.activity.record_stream(turn_id, true) { + self.controller.handle_agent_activity(turn_id).await + } else { + Ok(()) + } + } + AgenticEvent::ToolEvent { + turn_id, + tool_event, + .. + } => { + if let Some(activity) = project_tool_activity(tool_event) { + self.activity.record_tool_lifecycle(turn_id); + self.controller + .handle_agent_tool_activity(turn_id, activity) + .await + } else if self.activity.record_suppressed_tool_event(turn_id) { + self.controller.handle_agent_activity(turn_id).await + } else { + Ok(()) + } + } + AgenticEvent::DialogTurnCompleted { + turn_id, + success, + has_final_response, + .. + } => { + let summary = self.finish_activity(turn_id).and_then(|summary| { + (has_final_response.unwrap_or(true)) + .then_some(summary.final_response) + .flatten() + }); + let status = if *success == Some(false) { + LoopxAgentTurnStatus::Failed + } else { + LoopxAgentTurnStatus::Completed + }; + self.controller + .handle_agent_terminal(turn_id, status, summary, false) + .await + } + AgenticEvent::DialogTurnFailed { + turn_id, + error, + error_category, + error_detail, + .. + } => { + let _ = self.finish_activity(turn_id); + let summary = failure_summary(error, error_detail.as_ref()); + let blocks_repository = + failure_blocks_repository(error_category.as_ref(), error_detail.as_ref()); + self.controller + .handle_agent_terminal( + turn_id, + LoopxAgentTurnStatus::Failed, + Some(summary), + blocks_repository, + ) + .await + } + AgenticEvent::DialogTurnCancelled { turn_id, .. } => { + let _ = self.finish_activity(turn_id); + self.controller + .handle_agent_terminal(turn_id, LoopxAgentTurnStatus::Cancelled, None, false) + .await + } + AgenticEvent::DialogTurnInterrupted { turn_id, .. } => { + let _ = self.finish_activity(turn_id); + self.controller + .handle_agent_terminal(turn_id, LoopxAgentTurnStatus::Interrupted, None, false) + .await + } + _ => Ok(()), + }; + result.map_err(EventBusError::subscriber) + } +} + +fn append_bounded_text(buffer: &mut String, text: &str, max_chars: usize) { + let remaining = max_chars.saturating_sub(buffer.chars().count()); + if remaining > 0 { + buffer.extend(text.chars().take(remaining)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn activity_gate_coalesces_stream_chunks_but_allows_forced_boundaries() { + let gate = ActivityGate::default(); + let now = Instant::now(); + + assert!(gate.record_stream_at("turn-1", now, false)); + assert!(!gate.record_stream_at("turn-1", now + Duration::from_millis(500), false)); + assert!(gate.record_stream_at("turn-1", now + Duration::from_millis(600), true)); + assert!(!gate.record_stream_at("turn-1", now + Duration::from_secs(1), false)); + assert!(gate.record_stream_at("turn-1", now + Duration::from_secs(3), false)); + } + + #[test] + fn clearing_activity_allows_the_next_generation_to_persist_immediately() { + let gate = ActivityGate::default(); + let now = Instant::now(); + assert!(gate.record_stream_at("turn-1", now, false)); + let summary = gate.finish("turn-1").expect("activity summary"); + assert_eq!(summary.stream_events, 1); + assert!(gate.record_stream_at("turn-1", now + Duration::from_millis(1), false)); + } + + #[test] + fn stream_flood_produces_one_liveness_checkpoint_inside_the_interval() { + let gate = ActivityGate::default(); + let now = Instant::now(); + let checkpoints = (0..10_000) + .filter(|_| gate.record_stream_at("turn-1", now, false)) + .count(); + + assert_eq!(checkpoints, 1); + assert_eq!( + gate.finish("turn-1").expect("activity summary"), + ActivitySummary { + stream_events: 10_000, + persisted_checkpoints: 1, + ..ActivitySummary::default() + } + ); + } + + #[test] + fn final_response_uses_only_the_latest_model_round() { + let gate = ActivityGate::default(); + gate.start_round("turn-1", "round-1"); + gate.record_text("turn-1", "round-1", Some("attempt-1"), "intermediate"); + gate.start_round("turn-1", "round-2"); + gate.record_text("turn-1", "round-2", Some("attempt-1"), "final "); + gate.record_text("turn-1", "round-2", Some("attempt-1"), "answer"); + + assert_eq!( + gate.finish("turn-1") + .and_then(|summary| summary.final_response), + Some("final answer".to_string()) + ); + } + + #[test] + fn superseded_attempt_does_not_leak_partial_text() { + let gate = ActivityGate::default(); + gate.start_round("turn-1", "round-1"); + gate.record_text("turn-1", "round-1", Some("attempt-1"), "partial"); + gate.record_text("turn-1", "round-1", Some("attempt-2"), "retry answer"); + + assert_eq!( + gate.finish("turn-1") + .and_then(|summary| summary.final_response), + Some("retry answer".to_string()) + ); + } +} + +fn failure_summary(error: &str, detail: Option<&AiErrorDetail>) -> String { + detail + .and_then(|detail| detail.provider_message.as_deref()) + .filter(|message| !message.trim().is_empty()) + .unwrap_or(error) + .chars() + .take(1_000) + .collect() +} + +fn failure_blocks_repository( + category: Option<&ErrorCategory>, + detail: Option<&AiErrorDetail>, +) -> bool { + let category = category.or_else(|| detail.map(|detail| &detail.category)); + matches!( + category, + Some( + ErrorCategory::Network + | ErrorCategory::Auth + | ErrorCategory::RateLimit + | ErrorCategory::Timeout + | ErrorCategory::ProviderQuota + | ErrorCategory::ProviderBilling + | ErrorCategory::ProviderUnavailable + | ErrorCategory::Permission + | ErrorCategory::InvalidRequest + | ErrorCategory::ModelError + ) + ) +} diff --git a/src/crates/assembly/core/src/miniapp/loopx/tool_activity.rs b/src/crates/assembly/core/src/miniapp/loopx/tool_activity.rs new file mode 100644 index 0000000000..6595d6d0dd --- /dev/null +++ b/src/crates/assembly/core/src/miniapp/loopx/tool_activity.rs @@ -0,0 +1,349 @@ +use openbitfun_events::ToolEventData; +use openbitfun_services_core::session_usage::redaction::redact_usage_input_summary; +use serde_json::Value; +use std::collections::BTreeMap; + +const MAX_TOOL_SUMMARY_CHARS: usize = 240; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ToolActivityProjection { + pub tool_name: String, + pub state: &'static str, + pub message: String, + pub details: BTreeMap, + pub current_tool: Option, + pub important: bool, +} + +pub(super) fn project_tool_activity(event: &ToolEventData) -> Option { + let tool_name = event.effective_tool_name().to_string(); + let mut details = BTreeMap::from([ + ("activity".to_string(), activity_state(event)?.to_string()), + ("toolName".to_string(), tool_name.clone()), + ]); + let (message, current_tool, important) = match event { + ToolEventData::Queued { position, .. } => { + details.insert("queuePosition".to_string(), position.to_string()); + ( + format!("Tool queued: {tool_name}"), + Some(tool_name.clone()), + false, + ) + } + ToolEventData::Waiting { dependencies, .. } => { + if !dependencies.is_empty() { + details.insert("dependencies".to_string(), dependencies.join(", ")); + } + ( + format!("Tool waiting: {tool_name}"), + Some(tool_name.clone()), + false, + ) + } + ToolEventData::Started { params, .. } => { + if let Some(summary) = tool_input_summary(&tool_name, params) { + details.insert("summary".to_string(), summary); + } + ( + format!("Tool started: {tool_name}"), + Some(tool_name.clone()), + false, + ) + } + ToolEventData::ConfirmationNeeded { params, .. } => { + if let Some(summary) = tool_input_summary(&tool_name, params) { + details.insert("summary".to_string(), summary); + } + ( + format!("Tool needs confirmation: {tool_name}"), + Some(tool_name.clone()), + false, + ) + } + ToolEventData::Confirmed { .. } => ( + format!("Tool confirmed: {tool_name}"), + Some(tool_name.clone()), + false, + ), + ToolEventData::Rejected { .. } => (format!("Tool rejected: {tool_name}"), None, true), + ToolEventData::Completed { + params, + result, + duration_ms, + .. + } => { + details.insert("durationMs".to_string(), duration_ms.to_string()); + if let Some(summary) = params + .as_ref() + .and_then(|params| tool_input_summary(&tool_name, params)) + { + details.insert("summary".to_string(), summary); + } + insert_result_facts(&tool_name, result, &mut details); + (format!("Tool completed: {tool_name}"), None, false) + } + ToolEventData::Failed { + params, + error, + duration_ms, + .. + } => { + if let Some(params) = params { + if let Some(summary) = tool_input_summary(&tool_name, params) { + details.insert("summary".to_string(), summary); + } + } + if let Some(duration_ms) = duration_ms { + details.insert("durationMs".to_string(), duration_ms.to_string()); + } + let error_summary = redact_usage_input_summary(error, MAX_TOOL_SUMMARY_CHARS).value; + let input_summary = params + .as_ref() + .and_then(|params| tool_input_summary(&tool_name, params)); + details.insert( + "summary".to_string(), + match input_summary { + Some(input) => format!("{input} — {error_summary}"), + None => error_summary, + }, + ); + (format!("Tool failed: {tool_name}"), None, true) + } + ToolEventData::Cancelled { + params, + reason, + duration_ms, + .. + } => { + if let Some(params) = params { + if let Some(summary) = tool_input_summary(&tool_name, params) { + details.insert("summary".to_string(), summary); + } + } + if let Some(duration_ms) = duration_ms { + details.insert("durationMs".to_string(), duration_ms.to_string()); + } + let reason_summary = redact_usage_input_summary(reason, MAX_TOOL_SUMMARY_CHARS).value; + let input_summary = params + .as_ref() + .and_then(|params| tool_input_summary(&tool_name, params)); + details.insert( + "summary".to_string(), + match input_summary { + Some(input) => format!("{input} — {reason_summary}"), + None => reason_summary, + }, + ); + (format!("Tool cancelled: {tool_name}"), None, false) + } + ToolEventData::EarlyDetected { .. } + | ToolEventData::ParamsPartial { .. } + | ToolEventData::Progress { .. } + | ToolEventData::Streaming { .. } + | ToolEventData::StreamChunk { .. } => return None, + }; + + Some(ToolActivityProjection { + tool_name, + state: activity_state(event).expect("projected tool events have a stable state"), + message, + details, + current_tool, + important, + }) +} + +fn activity_state(event: &ToolEventData) -> Option<&'static str> { + match event { + ToolEventData::Queued { .. } => Some("queued"), + ToolEventData::Waiting { .. } => Some("waiting"), + ToolEventData::Started { .. } => Some("started"), + ToolEventData::ConfirmationNeeded { .. } => Some("confirmation"), + ToolEventData::Confirmed { .. } => Some("confirmed"), + ToolEventData::Rejected { .. } => Some("rejected"), + ToolEventData::Completed { .. } => Some("completed"), + ToolEventData::Failed { .. } => Some("failed"), + ToolEventData::Cancelled { .. } => Some("cancelled"), + ToolEventData::EarlyDetected { .. } + | ToolEventData::ParamsPartial { .. } + | ToolEventData::Progress { .. } + | ToolEventData::Streaming { .. } + | ToolEventData::StreamChunk { .. } => None, + } +} + +fn tool_input_summary(tool_name: &str, params: &Value) -> Option { + let fields = params.as_object()?; + let value = match tool_name { + "ExecCommand" => string_field(fields, &["cmd", "command"]), + "Read" => string_field(fields, &["file_path", "path"]), + "Write" => string_field( + fields, + &["file_path", "filePath", "path", "target_path", "targetPath"], + ) + .or_else(|| { + string_field(fields, &["payload"]).and_then(|value| write_payload_path(&value)) + }), + "Edit" => string_field( + fields, + &["file_path", "filePath", "path", "target_path", "targetPath"], + ), + "LS" => string_field(fields, &["path"]), + "WebFetch" => string_field(fields, &["url", "request_url"]), + "Grep" => { + let pattern = string_field(fields, &["pattern"])?; + match string_field(fields, &["path"]) { + Some(path) => Some(format!("{pattern} in {path}")), + None => Some(pattern), + } + } + _ => None, + }?; + Some(redact_usage_input_summary(&value, MAX_TOOL_SUMMARY_CHARS).value) +} + +fn write_payload_path(payload: &str) -> Option { + payload + .lines() + .next()? + .trim() + .strip_prefix("+++") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_matches(|ch| ch == '{' || ch == '}').to_string()) +} + +fn string_field(fields: &serde_json::Map, names: &[&str]) -> Option { + names + .iter() + .find_map(|name| fields.get(*name).and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn insert_result_facts(tool_name: &str, result: &Value, details: &mut BTreeMap) { + let Some(fields) = result.as_object() else { + return; + }; + let facts: &[(&str, &[&str])] = match tool_name { + "ExecCommand" => &[("exitCode", &["exit_code", "exitCode"])], + "Grep" => &[ + ("matchCount", &["total_matches", "totalMatches"]), + ("fileCount", &["file_count", "fileCount"]), + ], + "LS" => &[("entryCount", &["total"])], + "Read" => &[("lineCount", &["lines_read", "linesRead"])], + "WebFetch" => &[("contentLength", &["content_length", "contentLength"])], + _ => &[], + }; + for (detail_name, field_names) in facts { + if let Some(value) = field_names + .iter() + .find_map(|field_name| fields.get(*field_name)) + .and_then(json_scalar_label) + { + details.insert((*detail_name).to_string(), value); + } + } + if tool_name == "WebFetch" { + if let Some(title) = string_field(fields, &["title"]) { + details.insert( + "title".to_string(), + redact_usage_input_summary(&title, MAX_TOOL_SUMMARY_CHARS).value, + ); + } + } +} + +fn json_scalar_label(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + Value::Bool(value) => Some(value.to_string()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openbitfun_events::ToolEventIdentity; + + #[test] + fn partial_parameters_are_not_projected_as_user_log_events() { + let event = ToolEventData::ParamsPartial { + identity: ToolEventIdentity::direct("tool-1", "ExecCommand"), + params: "{\"cmd\":\"cargo".to_string(), + }; + + assert!(project_tool_activity(&event).is_none()); + } + + #[test] + fn command_summary_is_bounded_and_redacts_secrets() { + let event = ToolEventData::Started { + identity: ToolEventIdentity::direct("tool-1", "ExecCommand"), + params: serde_json::json!({ + "cmd": "curl --api-key secret-value https://example.test" + }), + timeout_seconds: None, + }; + + let projection = project_tool_activity(&event).expect("started tool is projected"); + let summary = projection.details.get("summary").expect("summary"); + assert!(summary.contains("--api-key [redacted]")); + assert!(!summary.contains("secret-value")); + } + + #[test] + fn completed_tool_projects_small_result_facts() { + let event = ToolEventData::Completed { + identity: ToolEventIdentity::direct("tool-1", "Grep"), + params: None, + result: serde_json::json!({ "total_matches": 12, "file_count": 3 }), + result_for_assistant: None, + image_attachments: None, + duration_ms: 42, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: Some(42), + }; + + let projection = project_tool_activity(&event).expect("completed tool is projected"); + assert_eq!( + projection.details.get("matchCount").map(String::as_str), + Some("12") + ); + assert_eq!( + projection.details.get("fileCount").map(String::as_str), + Some("3") + ); + assert_eq!( + projection.details.get("durationMs").map(String::as_str), + Some("42") + ); + } + + #[test] + fn write_activity_projects_only_the_target_path() { + let event = ToolEventData::Started { + identity: ToolEventIdentity::direct("tool-1", "Write"), + params: serde_json::json!({ + "payload": "+++ src/window/focus.ts\nprivate implementation details" + }), + timeout_seconds: None, + }; + + let projection = project_tool_activity(&event).expect("started write is projected"); + assert_eq!( + projection.details.get("summary").map(String::as_str), + Some("src/window/focus.ts") + ); + assert!(!projection + .details + .values() + .any(|value| value.contains("private implementation details"))); + } +} diff --git a/src/crates/assembly/core/src/miniapp/mod.rs b/src/crates/assembly/core/src/miniapp/mod.rs index 96d0019bf8..15f97166dd 100644 --- a/src/crates/assembly/core/src/miniapp/mod.rs +++ b/src/crates/assembly/core/src/miniapp/mod.rs @@ -9,6 +9,8 @@ pub mod exporter; pub mod host_dispatch; pub mod js_worker; pub mod js_worker_pool; +#[cfg(feature = "agent-runtime")] +pub mod loopx; pub mod manager; pub mod runtime_detect; pub mod storage; @@ -20,6 +22,7 @@ pub use openbitfun_product_domains::miniapp::draft::{MiniAppDraft, MiniAppDraftM pub use openbitfun_product_domains::miniapp::{ agent_bridge, ai_bridge, bridge_builder, lifecycle, permission_policy, rate_limit, types, }; +pub use openbitfun_product_domains::miniapp::builtin::builtin_content_hash; pub use builtin::{seed_builtin_miniapps, BuiltinApp, BUILTIN_APPS}; pub use exporter::{ExportCheckResult, ExportOptions, ExportResult, ExportTarget, MiniAppExporter}; diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index ad77b9068b..beebb90a75 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -25,6 +25,37 @@ where .collect()) } +fn deserialize_datetime_millis_or_rfc3339<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum CompatibleTimestamp { + Milliseconds(i64), + Rfc3339(String), + } + + match CompatibleTimestamp::deserialize(deserializer)? { + CompatibleTimestamp::Milliseconds(value) => { + chrono::DateTime::::from_timestamp_millis(value).ok_or_else(|| { + ::custom(format!( + "last_modified timestamp is out of range: {value}" + )) + }) + } + CompatibleTimestamp::Rfc3339(value) => chrono::DateTime::parse_from_rfc3339(&value) + .map(|timestamp| timestamp.with_timezone(&chrono::Utc)) + .map_err(|error| { + ::custom(format!( + "last_modified must be Unix milliseconds or RFC3339: {error}" + )) + }), + } +} + /// Web UI font preferences (settings → basics). Keys match `FontPreference` in the frontend (camelCase). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -76,7 +107,10 @@ pub struct GlobalConfig { /// independent from the OpenBitFun application version stored in `version`. pub schema_version: u32, pub version: String, - #[serde(with = "chrono::serde::ts_milliseconds")] + #[serde( + serialize_with = "chrono::serde::ts_milliseconds::serialize", + deserialize_with = "deserialize_datetime_millis_or_rfc3339" + )] pub last_modified: chrono::DateTime, } @@ -2326,6 +2360,25 @@ mod tests { assert_eq!(limited.ai.max_rounds, 37); } + #[test] + fn legacy_global_config_accepts_rfc3339_last_modified() { + let legacy_timestamp = "2026-08-26T11:24:58.7496147Z"; + let expected = chrono::DateTime::parse_from_rfc3339(legacy_timestamp) + .expect("fixture timestamp should be valid") + .with_timezone(&chrono::Utc); + let config: GlobalConfig = serde_json::from_value(serde_json::json!({ + "last_modified": legacy_timestamp + })) + .expect("legacy RFC3339 last_modified should deserialize"); + + assert_eq!(config.last_modified, expected); + let serialized = serde_json::to_value(config).expect("config should serialize"); + assert_eq!( + serialized["last_modified"], + serde_json::json!(expected.timestamp_millis()) + ); + } + #[test] fn user_tool_groups_default_to_version_one_without_persisted_groups() { let mut value = current_global_config_with(serde_json::json!({})); @@ -2721,6 +2774,40 @@ mod tests { assert!(config.inline_think_in_text); } + #[test] + fn deserializes_empty_string_custom_headers_as_absent() { + let config: AIModelConfig = serde_json::from_value(serde_json::json!({ + "id": "model_1", + "name": "Provider", + "provider": "openai", + "model_name": "test-model", + "base_url": "https://example.com/v1", + "api_key": "key", + "enabled": true, + "custom_headers": "" + })) + .expect("legacy empty custom_headers should deserialize"); + + assert!(config.custom_headers.is_none()); + } + + #[test] + fn default_chat_category_supports_text_generation_without_capability_tags() { + let config: AIModelConfig = serde_json::from_value(serde_json::json!({ + "id": "model_1", + "name": "Provider", + "provider": "openai", + "model_name": "test-model", + "base_url": "https://example.com/v1", + "api_key": "key", + "enabled": true + })) + .expect("model without capability tags should deserialize"); + + assert!(config.capabilities.is_empty()); + assert!(config.supports_text_generation()); + } + #[test] fn default_ai_config_uses_generous_stream_timeouts() { let config = AIConfig::default(); diff --git a/src/crates/assembly/core/src/service/session_projection_store.rs b/src/crates/assembly/core/src/service/session_projection_store.rs index 3887b890f6..fd0d72bf1e 100644 --- a/src/crates/assembly/core/src/service/session_projection_store.rs +++ b/src/crates/assembly/core/src/service/session_projection_store.rs @@ -18,6 +18,9 @@ use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +const DEFAULT_RUNTIME_EVENT_PAGE_SIZE: usize = 200; +const MAX_RUNTIME_EVENT_PAGE_SIZE: usize = 1_000; + /// One appended line. `streamId` identifies the Runtime process that wrote it, /// so a log left by an older process is never mistaken for current progress. #[derive(serde::Serialize, serde::Deserialize)] @@ -28,6 +31,21 @@ struct LoggedEvent { event: AgenticEvent, } +#[derive(Debug, Clone)] +pub struct RuntimeEventRecord { + pub stream_id: String, + pub cursor: u64, + pub event: AgenticEvent, +} + +#[derive(Debug, Clone)] +pub struct RuntimeEventPage { + pub stream_id: String, + pub events: Vec, + pub next_cursor: u64, + pub has_more: bool, +} + /// Open append handles, keyed by Session. Holding the handle is what makes a /// per-event write an append to an already-open file rather than an open/close /// cycle per token. @@ -57,6 +75,94 @@ pub fn runtime_event_log_dir( path_manager.product_home_dir().join("runtime-events") } +pub fn read_runtime_events_since( + root: &Path, + session_id: &str, + stream_id: Option<&str>, + after_cursor: u64, + requested_limit: Option, +) -> Result, String> { + let path = log_path(root, session_id); + let file = match File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "Failed to open runtime event log for {session_id}: {error}" + )) + } + }; + + let limit = requested_limit + .map(|value| value as usize) + .unwrap_or(DEFAULT_RUNTIME_EVENT_PAGE_SIZE) + .clamp(1, MAX_RUNTIME_EVENT_PAGE_SIZE); + let mut latest_stream_id: Option = None; + let mut active_after_cursor = after_cursor; + let mut events = Vec::new(); + let mut has_more = false; + let mut next_cursor = after_cursor; + + for line in BufReader::new(file).lines() { + let line = line.map_err(|error| { + format!("Failed to read runtime event log for {session_id}: {error}") + })?; + if line.trim().is_empty() { + continue; + } + let record = match serde_json::from_str::(&line) { + Ok(record) => record, + Err(_) => break, + }; + match latest_stream_id.as_deref() { + Some(current) if current != record.stream_id => { + active_after_cursor = match stream_id { + Some(requested) if requested == record.stream_id => after_cursor, + Some(_) => 0, + None => after_cursor, + }; + events.clear(); + has_more = false; + next_cursor = active_after_cursor; + latest_stream_id = Some(record.stream_id.clone()); + } + None => { + active_after_cursor = match stream_id { + Some(requested) if requested == record.stream_id => after_cursor, + Some(_) => 0, + None => after_cursor, + }; + next_cursor = active_after_cursor; + latest_stream_id = Some(record.stream_id.clone()); + } + _ => {} + } + if record.cursor <= active_after_cursor { + continue; + } + if events.len() >= limit { + has_more = true; + continue; + } + next_cursor = record.cursor; + events.push(RuntimeEventRecord { + stream_id: record.stream_id, + cursor: record.cursor, + event: record.event, + }); + } + + let Some(latest_stream_id) = latest_stream_id else { + return Ok(None); + }; + Ok(Some(RuntimeEventPage { + stream_id: latest_stream_id, + events, + next_cursor, + has_more, + })) +} + impl FileSessionProjectionStore { pub fn new(root: PathBuf) -> Self { if let Err(error) = std::fs::create_dir_all(&root) { @@ -224,6 +330,25 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn output_pages_reset_cursor_when_stream_changes() { + let root = temp_root(); + let store = FileSessionProjectionStore::new(root.clone()); + + store.append("session-1", "stream-old", 100, &text("session-1", "stale")); + store.append("session-1", "stream-new", 1, &text("session-1", "fresh")); + + let page = + read_runtime_events_since(&root, "session-1", Some("stream-old"), 100, Some(10)) + .expect("page read succeeds") + .expect("latest stream exists"); + assert_eq!(page.stream_id, "stream-new"); + assert_eq!(page.events.len(), 1); + assert_eq!(page.events[0].cursor, 1); + assert_eq!(page.next_cursor, 1); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn a_torn_final_line_keeps_everything_already_durable() { let root = temp_root(); diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index 5707ea031a..4c8f422840 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -557,6 +557,10 @@ pub enum ToolEventData { Completed { #[serde(flatten)] identity: ToolEventIdentity, + /// Tool input snapshot so late projections (task timelines) can show + /// what the tool operated on without replaying the whole turn. + #[serde(default, skip_serializing_if = "Option::is_none")] + params: Option, result: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] result_for_assistant: Option, @@ -575,6 +579,8 @@ pub enum ToolEventData { Failed { #[serde(flatten)] identity: ToolEventIdentity, + #[serde(default, skip_serializing_if = "Option::is_none")] + params: Option, error: String, #[serde(default, skip_serializing_if = "Option::is_none")] duration_ms: Option, @@ -590,6 +596,8 @@ pub enum ToolEventData { Cancelled { #[serde(flatten)] identity: ToolEventIdentity, + #[serde(default, skip_serializing_if = "Option::is_none")] + params: Option, reason: String, #[serde(default, skip_serializing_if = "Option::is_none")] duration_ms: Option, @@ -935,6 +943,7 @@ mod tests { #[test] fn completed_tool_reports_total_and_execution_duration() { let event = ToolEventData::Completed { + params: None, identity: ToolEventIdentity::direct("tool-1", "write_file"), result: serde_json::json!({ "ok": true }), result_for_assistant: None, @@ -979,6 +988,7 @@ mod tests { #[test] fn completed_tool_serializes_image_attachments() { let event = ToolEventData::Completed { + params: None, identity: ToolEventIdentity::direct("tool-image-1", "view_image"), result: serde_json::json!({ "path": "preview.png" }), result_for_assistant: Some("Image attached".to_string()), @@ -1002,6 +1012,7 @@ mod tests { #[test] fn failed_tool_reports_best_effort_total_duration() { let event = ToolEventData::Failed { + params: None, identity: ToolEventIdentity::direct("tool-1", "write_file"), error: "failed".to_string(), duration_ms: Some(120), @@ -1020,6 +1031,7 @@ mod tests { #[test] fn cancelled_tool_reports_best_effort_total_duration() { let event = ToolEventData::Cancelled { + params: None, identity: ToolEventIdentity::direct("tool-1", "write_file"), reason: "cancelled".to_string(), duration_ms: Some(120), diff --git a/src/crates/contracts/events/src/frontend_projection.rs b/src/crates/contracts/events/src/frontend_projection.rs index a339ba4f40..2ead1b200c 100644 --- a/src/crates/contracts/events/src/frontend_projection.rs +++ b/src/crates/contracts/events/src/frontend_projection.rs @@ -154,6 +154,7 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option String { - format!("miniapp-agent-{}-{}", app_id, sequence) + // The process-local sequence resets on restart while a reused hidden + // session keeps its persisted dialog_turn_ids. A restarted host would + // otherwise regenerate "miniapp-agent-{app_id}-1" and collide with a turn + // already recorded on that session ("Dialog turn already exists"). Anchor + // the id to wall-clock time so it stays unique across restarts. + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + format!("miniapp-agent-{}-{}-{}", app_id, sequence, stamp) } pub fn agent_run_id_from_request( @@ -474,9 +483,10 @@ mod tests { agent_run_id_from_request("app-1", Some(" run-1 "), 9), "run-1" ); - assert_eq!( - agent_run_id_from_request("app-1", Some(" "), 9), - "miniapp-agent-app-1-9" + let generated = agent_run_id_from_request("app-1", Some(" "), 9); + assert!( + generated.starts_with("miniapp-agent-app-1-9-"), + "expected a restart-unique run id, got: {generated}" ); let plan = build_agent_submission_plan( 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 aa8d431713..610a3c2b94 100644 --- a/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs +++ b/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs @@ -1,23 +1,68 @@ //! Bridge script builder — generate window.app Runtime Adapter (OpenBitFun Hosted) for iframe. +use crate::miniapp::loopx::private_bridge_extension; use crate::miniapp::types::{EsmDep, MiniAppPermissions}; 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.*, deck.*, chat.*, clipboard.*, lifecycle, and events. Verified +/// built-in product surfaces may receive an additional private namespace; it +/// is not part of the public MiniApp API. pub fn build_bridge_script( app_id: &str, app_data_dir: &str, workspace_dir: &str, appearance_mode: &str, platform: &str, +) -> String { + build_bridge_script_internal( + app_id, + app_data_dir, + workspace_dir, + appearance_mode, + platform, + true, + ) +} + +/// Marketplace compilation always receives the public MiniApp API, even if an +/// imported package attempts to spoof a reserved built-in id. +pub fn build_market_bridge_script( + app_id: &str, + app_data_dir: &str, + workspace_dir: &str, + appearance_mode: &str, + platform: &str, +) -> String { + build_bridge_script_internal( + app_id, + app_data_dir, + workspace_dir, + appearance_mode, + platform, + false, + ) +} + +fn build_bridge_script_internal( + app_id: &str, + app_data_dir: &str, + workspace_dir: &str, + appearance_mode: &str, + platform: &str, + allow_private_builtin_extensions: bool, ) -> String { let app_id_esc = escape_js_str(app_id); let app_data_esc = escape_js_str(app_data_dir); let workspace_esc = escape_js_str(workspace_dir); let appearance_mode_esc = escape_js_str(appearance_mode); let platform_esc = escape_js_str(platform); + let private_builtin_extension = if allow_private_builtin_extensions { + private_bridge_extension(app_id).unwrap_or_default() + } else { + "" + }; format!( r#" @@ -171,6 +216,8 @@ pub fn build_bridge_script( offEvent: (fn) => app.off('agent:event', fn), }}, + {private_builtin_extension} + // 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. @@ -314,7 +361,8 @@ pub fn build_bridge_script( app_data_esc = app_data_esc, workspace_esc = workspace_esc, appearance_mode_esc = appearance_mode_esc, - platform_esc = platform_esc + platform_esc = platform_esc, + private_builtin_extension = private_builtin_extension, ) } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs index b8ec3dd6d0..57ac592fc3 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin.rs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin.rs @@ -8,7 +8,7 @@ use crate::miniapp::ports::{MiniAppPortFuture, MiniAppPortResult}; use crate::miniapp::storage::{ build_package_json, ESM_DEPS_JSON, INDEX_HTML, STYLE_CSS, UI_JS, WORKER_JS, }; -use crate::miniapp::types::MiniAppMeta; +use crate::miniapp::types::{EsmDep, MiniAppMeta, MiniAppSource}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -157,6 +157,16 @@ pub const BUILTIN_APPS: &[BuiltinMiniAppBundle] = &[ worker_js: include_str!("builtin/assets/ppt-live/worker.js"), esm_dependencies_json: include_str!("builtin/assets/ppt-live/esm_dependencies.json"), }, + BuiltinMiniAppBundle { + id: "builtin-bitfun-loopx", + version: 15, + meta_json: include_str!("builtin/assets/bitfun-loopx/meta.json"), + html: include_str!("builtin/assets/bitfun-loopx/index.html"), + css: include_str!("builtin/assets/bitfun-loopx/style.css"), + ui_js: include_str!("builtin/assets/bitfun-loopx/ui.js"), + worker_js: include_str!("builtin/assets/bitfun-loopx/worker.js"), + esm_dependencies_json: include_str!("builtin/assets/bitfun-loopx/esm_dependencies.json"), + }, ]; pub fn builtin_content_hash(app: &BuiltinMiniAppBundle) -> String { @@ -174,6 +184,23 @@ pub fn builtin_content_hash(app: &BuiltinMiniAppBundle) -> String { format!("sha256:{}", hex_encode(&hasher.finalize())) } +/// Whether installed source files still match the bundled built-in assets +/// verbatim. Privileged host bridges use this to refuse driving from locally +/// modified built-in content. The meta.json identity/timestamp rewrite performed +/// at seed time is intentionally excluded. +pub fn builtin_source_matches(source: &MiniAppSource, bundle: &BuiltinMiniAppBundle) -> bool { + let Ok(bundled_esm_deps) = serde_json::from_str::>(bundle.esm_dependencies_json) + else { + return false; + }; + source.html == bundle.html + && source.css == bundle.css + && source.ui_js == bundle.ui_js + && source.worker_js == bundle.worker_js + && source.esm_dependencies == bundled_esm_deps + && source.npm_dependencies.is_empty() +} + pub fn build_builtin_install_marker( app: &BuiltinMiniAppBundle, content_hash: &str, @@ -342,9 +369,9 @@ mod tests { // Version bumps should only touch bundle registration and seed runtime, not tests. use super::{ - build_builtin_seed_artifacts, builtin_content_hash, seed_builtin_miniapp_with_host, - BuiltinInstallMarker, BuiltinMiniAppSeedBundleRequest, BuiltinMiniAppSeedHost, - BuiltinMiniAppSeedOutcome, BuiltinSeedArtifacts, BUILTIN_APPS, + build_builtin_seed_artifacts, builtin_content_hash, builtin_source_matches, + seed_builtin_miniapp_with_host, BuiltinInstallMarker, BuiltinMiniAppSeedBundleRequest, + BuiltinMiniAppSeedHost, BuiltinMiniAppSeedOutcome, BuiltinSeedArtifacts, BUILTIN_APPS, }; use crate::miniapp::ports::{MiniAppPortFuture, MiniAppPortResult}; use std::sync::{Arc, Mutex}; @@ -361,6 +388,7 @@ mod tests { "builtin-regex-playground", "builtin-coding-selfie", "builtin-ppt-live", + "builtin-bitfun-loopx", ] ); @@ -374,6 +402,63 @@ mod tests { } } + #[test] + fn builtin_miniapp_meta_and_deps_parse_into_product_types() { + use crate::miniapp::types::{EsmDep, MiniAppMeta}; + + for app in BUILTIN_APPS { + let meta: MiniAppMeta = serde_json::from_str(app.meta_json).unwrap_or_else(|e| { + panic!( + "builtin '{}' meta.json does not parse as MiniAppMeta: {e}", + app.id + ) + }); + assert_eq!( + meta.id, app.id, + "builtin '{}' meta id must match the bundle id", + app.id + ); + assert!( + !meta.name.trim().is_empty(), + "builtin '{}' meta name must not be empty", + app.id + ); + assert!( + !meta.icon.trim().is_empty(), + "builtin '{}' meta icon must not be empty", + app.id + ); + let i18n = meta.i18n.as_ref().unwrap_or_else(|| { + panic!("builtin '{}' meta.json must carry i18n locales", app.id) + }); + assert!( + i18n.locales.contains_key("zh-CN"), + "builtin '{}' i18n must cover zh-CN", + app.id + ); + assert!( + i18n.locales.contains_key("en-US"), + "builtin '{}' i18n must cover en-US", + app.id + ); + + let deps: Vec = + serde_json::from_str(app.esm_dependencies_json).unwrap_or_else(|e| { + panic!( + "builtin '{}' esm_dependencies.json does not parse as an ESM dep list: {e}", + app.id + ) + }); + for dep in &deps { + assert!( + !dep.name.trim().is_empty(), + "builtin '{}' has an ESM dependency without a name", + app.id + ); + } + } + } + #[derive(Default)] struct FakeSeedHost { now_ms: i64, @@ -632,4 +717,32 @@ mod tests { assert!(!app.html.contains("href=\"./style.css\"")); assert!(app.css.contains("--openbitfun-bg")); } + + #[test] + fn builtin_source_matches_detects_modified_source() { + use crate::miniapp::types::{EsmDep, MiniAppSource}; + + let app = &BUILTIN_APPS[0]; + let pristine = MiniAppSource { + html: app.html.to_string(), + css: app.css.to_string(), + ui_js: app.ui_js.to_string(), + worker_js: app.worker_js.to_string(), + esm_dependencies: serde_json::from_str(app.esm_dependencies_json).unwrap(), + npm_dependencies: Vec::new(), + }; + assert!(builtin_source_matches(&pristine, app)); + + let mut modified_ui = pristine.clone(); + modified_ui.ui_js.push_str("\n// tampered"); + assert!(!builtin_source_matches(&modified_ui, app)); + + let mut modified_deps = pristine; + modified_deps.esm_dependencies.push(EsmDep { + name: "extra".to_string(), + version: None, + url: None, + }); + assert!(!builtin_source_matches(&modified_deps, app)); + } } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/AGENTS.md b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/AGENTS.md new file mode 100644 index 0000000000..5fd95400e9 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/AGENTS.md @@ -0,0 +1,366 @@ +# AGENTS.md — bitfun-loopx 内置 MiniApp 开发协定 + +本文件仅适用于本目录(bitfun-loopx 内置 MiniApp 的**唯一权威源码**),比仓库根 +`AGENTS.md`、`src/crates/contracts/AGENTS.md` 更具体,冲突时以本文件为准。开发 +流程细节见本目录 `README.md`「高效修改流程」;本文件同时钉住架构边界与 +**用户明确要求的迭代原则**。 + +## 架构基线(不可绕过) + +规范依据是 LoopX 官方 +[Custom Agent Runner Integration](https://github.com/huangruiteng/loopx/blob/main/docs/guides/custom-agent-runner-integration.zh-CN.md)。 +OpenBitFun 使用 mainstream cooperative runner 路径:保留自己的 Agent Runtime,把 LoopX +作为跨 Turn 的持久控制面合同。不要复制 Codex runner,也不要把 LoopX 改造成 BitFun +内部 workflow engine。 + +### 心智模型与唯一事实源 + +| Owner | 负责 | 禁止负责 | +|---|---|---| +| LoopX CLI | goal、todo、claim、gate、quota、evidence、scheduler hint、已接受的 writeback 和 Goal 终局 | Agent 推理、OpenBitFun session、工具执行、workspace 创建 | +| BitFun runner/controller | 唤醒、队列、公平调度、workspace/session 生命周期、取消、UI 投影、断点恢复 | 重写 LoopX policy、代写 progress/quota、根据模型文本判定完成 | +| OpenBitFun Agent | 动态规划并执行一个有界动作、读取真实产物、验证 postcondition、按 packet 写回 LoopX | 保存第二套任务状态、创建 scheduler、创建额外 worktree | +| MiniApp UI | intake、任务列表、进度、gate 操作、cursor replay | 直接执行 shell/Git/network,或成为 goal/todo 真相源 | + +`LoopxTaskSnapshot` 只是 host-job 投影,允许保存 workspace、session、取消、恢复、UI +摘要和最近一次 LoopX 状态;它不是 LoopX registry 的副本。冲突时以 LoopX CLI 的 +durable readback 为准,禁止用本地计数、transcript 或 UI 状态覆盖它。 + +### 分层和代码归属 + +依赖方向必须保持 `UI/Desktop adapter -> assembly controller -> typed ports -> services`: + +| 路径 | Owner | +|---|---| +| 本目录 `index.html` / `ui.js` / `style.css` | 无 Node 的薄 UI;只调用经过验证的私有 `app.loopx` bridge | +| `src/apps/desktop/src/api/miniapp_loopx_api.rs` | 验证 built-in source、执行域和 Tauri request;只转发 typed controller 调用 | +| `src/crates/assembly/core/src/miniapp/loopx/controller.rs` | 进程级 host driver、batch/queue、公平轮转、恢复和状态投影 | +| `src/crates/assembly/core/src/miniapp/loopx/agent_adapter.rs` | 把通用 `AgentSubmissionPort` 适配成 `LoopxAgentPort`;使用标准 `agentic` coding Agent | +| `src/crates/contracts/product-domains/src/miniapp/loopx/*` | 稳定 DTO、ports 和纯 policy;不得执行进程、文件、网络或 Agent Runtime | +| `src/crates/services/services-integrations/src/miniapp/loopx_cli.rs` | 固定版本 sidecar 选择、typed CLI argv/JSON 翻译和 durable readback | +| `loopx_github.rs` / `loopx_workspace.rs` | GitHub intake adapter 与 Git/worktree service | +| `src/apps/desktop/src/lib.rs` | 只做 concrete provider 装配、事件订阅和 Tauri registration | + +具体 Agent host 自己通过 `LoopxAgentPort::available_capabilities` 报告技术能力。controller +只转交,CLI adapter 只校验和翻译。`available_capability` 表示执行机制存在,不是用户授权; +绝不能从 `granted_scopes`、gate approval 或模型能力推导它。以后接 Codex、Claude Code +或其他 Agent 时,应新增/替换 `LoopxAgentPort` adapter,不修改 LoopX controller 状态机。 + +### 一项一 Goal 模型 + +- 一个 GitHub issue/PR 对应一个 LoopX goal、一个 task 和一个独立 worktree。 +- 多 issue 批量是 MiniApp task/batch 层的聚合,不得把多个 issue 压进同一个 goal 的 todo。 +- todo 只表示该 goal 内的推进项、successor 或 user gate。 +- 同一仓库的 tasks 串行占用 repository slot;每次 durable settlement 是公平轮转边界。 +- 不同 tasks 可以共享 bare Git object cache,但不能共享可变 worktree、`node_modules`、 + build 输出或运行中的进程。 +- LoopX 不负责 clone;workspace service 不负责 todo、quota 或 Agent 决策。 + +### 每轮执行合同 + +每次唤醒必须从 durable state 重新开始,不能依赖上一轮模型记忆: + +1. controller 用只读 `turn plan` 对账当前 Goal、user channel 和 cadence;该读取不启动 Agent。 +2. 只有 LoopX 投影 `RunNow` 时,adapter 以宿主生成的稳定 Turn id 调用一次 + `quota should-run --turn-envelope`。这一次调用同时是执行 gate 和 Agent packet,禁止先 + 缓存一个 packet、再用另一个 packet 放行执行。 +3. `should_run=false`、wait、quiet、monitor-only、user-only 或 failed 状态不调用模型, + 也不消费 quota。 +4. re-entry instruction 只能携带当前 TurnEnvelope 的 selected action、user channel、 + required reads、boundary、execution policy、writeback、replan/task orchestration contract、 + detail refs、CLI prefix、registry 和 Turn identity。 +5. Agent 在 write-capable 工作前 claim selected todo,只执行一个有界动作,读取真实 + repository/test/CI/provider 结果进行验证,然后 complete/update/block/defer 或创建明确的 + successor,执行 `refresh-state`,最后才以同一 identity spend quota。 +6. Agent terminal 后,宿主只读 `turn plan` 与 history,核验完全匹配的 + `goal_id + agent_id + turn_id + selected todo/replan obligation` durable writeback 和 quota + receipt。durable writeback 缺失或错绑进入显式 recovery(NoDurableProgress 先走一次 + corrective turn);cancelled/interrupted turn 的 RetryRequired 同样进显式 recovery, + 宿主不得在 owner 打断后静默续跑。只有 Completed turn 的 RetryRequired(写回已验证、 + 仅 quota 回执缺失,即 CLI 假阴性结算;终局 frontier 下 guard 拒绝放行、无法补跑 + turn)例外:宿主按结算后 Goal 投影决定下一状态,并落一条重要 task event 记录回执 + 缺失;宿主不补写、不伪造回执,也不得静默丢弃该记录。 +7. 只有 LoopX 投影 `Complete` 或 `Archived` 时,host task 才能 Completed。 + 计划耗尽时(无 open todo、无 selected todo、无 waiting gate)CLI v0.5.1 会投影 + `should_run=true` 并携带 `replan_action_packet.obligation_id`:宿主必须驱动一轮绑定该 + obligation 的 autonomous replan turn(quota guard 放行,settlement 按 + `autonomous_replan` effect id 核验),由 agent 写回 successor todo、typed 终局 + (如 `coverage_backed_no_followup` + vision 闭环)或新 concrete blocker;CLI 的 + replan stall 机制约束连续空转。只有 `RunNow + 0 open todo` 且无 open replan + obligation 才是合同矛盾,必须 park(`plan_exhausted`)等 owner 决策;禁止宿主调用 + `goal-lifecycle stop` 来伪造终局。(2026-09-05 五 issue 实测修正:旧版把带 obligation + 的耗尽态一律当矛盾处理,导致 0/5 全部停在 recovery,goal 无法自主收尾。) + +re-entry instruction 必须稳定且轻量,不得缓存 todo 列表、cadence、project policy、上一轮 +摘要或 raw transcript。`last_agent_summary` 仅用于 UI,不参与执行、settlement 或恢复判断。 + +### Gate、调度和恢复 + +- `should_run=true` 优先于并存的 user action:独立安全 todo 可以继续,同时把具体 user + gate 投影到 UI;不能因为一个 gate 阻塞整个 frontier。 +- BitFun 是 `generic-cli / outer_controller / isolated-headless` runner。统一 scheduler + 管所有 task,不得每 issue 创建 timer,也不得调用 Codex App automation API。 +- LoopX `v0.5.1` 的 bootstrap 参数 `--codex-app-heartbeat no` 只是关闭上游遗留的 + Codex 专用 onboarding 分支,不代表 OpenBitFun 模拟 Codex App。 +- scheduler hint 有数值时按数值调度;当前 outer-controller packet 只有 cadence label 时, + 使用代码中明确的兼容间隔。只有 packet 要求 ACK 时才按 packet 的 exact argv ACK。 +- PR 生命周期监控(`continuous_monitor` / `issue_fix_pr_state_*_monitor` / + `issue_fix_track_*` todo)由 LoopX packet 驱动、agent 在轮内执行;宿主不调用 + `issue-fix pr-lifecycle`,也不压缩 maintainer correction。宿主只把 turn plan + envelope 的 selected todo 投影为任务快照 `currentTodo`(有界、非权威、Goal + 终局清除),UI 据此区分「PR 监控等待中」。当前 pin `v0.5.1` 不返回数值调度 + hint(60s 兼容间隔);上游 ≥v0.5.x 的 monitor_wait 数值 cadence([15,30,60] 分钟, + 宿主下限 15 分钟)在升级 pin 后经既有 `scheduler_hint_ms` 路径自动生效。 +- v0.5.1 下 monitor 类 todo(`*_monitor` 与 `issue_fix_track_*`,见 policy.rs + `is_loopx_monitor_action`)即使投影 `RunNow`,宿主也按 15 分钟兼容下限把 + re-check 驻留排队(锚点是该 goal 上一次 durable settlement 时间,不是新增宿主 + 收敛计数),期间让出 repository slot 给同仓库排队 issue;深度优先 sticky 续跑 + 对 monitor successor 不适用。该分类与 UI `isMonitorTodo` 镜像,修改需双侧同步。 +- runner 重启从 LoopX registry、host task snapshot 和 workspace readback 恢复,不能 replay + transcript 重建控制状态。结果不确定时保留数据并进入 recovery,不自动重试外部副作用。 + +### 允许保留的宿主能力 + +以下不是“嵌入式改写”,不得因清理架构而误删: + +- 打包的固定版本 LoopX sidecar、签名/版本/schema 校验,以及用户显式触发的 managed-source + fallback;它们属于可交付性和 process adapter。 +- GitHub issue/PR metadata intake;它属于外部 source adapter。 +- 每 item worktree、共享 bare object cache、显式 archive/reset 清理;它们属于 workspace + service 和 host-job 生命周期。 +- cursor event replay、Agent transient session、取消和公平队列;它们属于 runner/UI 体验。 + +OpenViking、语义偏好、反馈记忆和其他可选 LoopX extensions 不属于 issue-fix MiniApp 的 +核心闭环。没有独立产品需求、owner 和 capability negotiation 前,不得重新塞入本 +controller、environment DTO 或 UI。 + +### 禁止重新引入 + +- 不生成、转发、改写或缓存 `heartbeat-prompt`,不复制 Codex prompt/skill 目录约定。 +- 不创建 host-authored `LOOPX_AGENT_PLAYBOOK.md`、`.bitfun/loopx/intake-plan.json` 或类似 + workflow 镜像文件。 +- 不把上一轮 Agent 摘要、todo 摘要或 raw workflow packet 回灌成下一轮控制事实。 +- 不用字符串替换修改 LoopX packet,不裁剪/改写 LoopX durable todo 来让 envelope 通过。 +- 不维护宿主侧 autonomous-turn、stagnation、same-todo 等第二套收敛计数,也不自造 + `autonomous_budget_review` gate。 +- 不从 worktree diff、Agent exit code 或完成文本推断 durable progress。 +- 不补写 quota、不伪造 evidence、不启动 settlement-repair Agent,不接受其他 todo 的 + settlement 作为当前 selected todo 的成功。 +- 不让 UI/Worker 接触 shell、Git、文件或 network primitive,不向普通/市场 MiniApp 暴露 + `app.loopx` 私有 namespace。 +- 不为 convenience 绕过 typed ports 传 raw argv,也不把 services implementation 上移到 + assembly 或 Desktop API。 + +### 当前能力边界 + +- 当前只支持 Local Desktop workspace。Remote Workspace、Peer Device、Remote Control + 和 Detached Dispatch 必须明确返回 unsupported,禁止静默回落到 controller 本机。 +- 当前是 cooperative mainstream path:Agent 自己验证真实 postcondition 并写回,宿主再 + 独立核验 LoopX durable evidence;这不等于 experimental `turn run-once` qualification。 +- 在引入 `turn run-once` 前,必须先有 provider-neutral typed result、task-specific independent + validator,以及 retry/resume/replay 不重复产生 effect 的证明。 +- persisted DTO 新字段必须有默认值;旧字段/旧 action 要宽容读取并明确降级,不能通过删除 + registry、task snapshot 或 worktree 来“修复”升级问题。 + +## 设计原则:单一事实源,非必要不新增(用户要求,不可违背) + +新增任何功能、字段、按钮、面板之前,先确认现有实现是否已覆盖同一需求;能复用或派生的必须复用或派生。 + +- **同一事实只允许在一个位置表达和展示**。不同按钮实现相似功能、不同位置显示同一任务状态, + 必然产生联动同步负担,是 bug 的稳定来源:状态变化时两处必然失同步,或允许用户/模型在 + 两处选出矛盾组合。 +- **能从既有字段推导的信息不重复存储、不重复选择**,由消费端派生展示。新增字段前先过 + "能否从现有字段推导或合并"检查;推导关系存在的两个字段必须合并为一个枚举, + 支撑证据改为条件必填,由 schema 校验而非模型或用户自觉。 +- 实例(结构化汇报模板设计中的教训):并列的 `issue_verdict`(要不要修)与 + `upstream_status`(上游是否已修复)存在推导关系("上游已修复"⟺"无需我方修复"), + 必须合并为单枚举 + 条件必填的 `fixed_by` 链接,而不是让模型在两个字段里各选一次。 +- **人读字段禁止内部代号**。候选编号(C-1/C-2)、todo id、turn key、效果 id、字段名 + (durable_writeback 之类)不得出现在结论/进展/决策/下一步等给人读的字段里;必须展开为 + 指代内容的普通句子("在 dsh-plugin-desktop 内补兼容层",而不是 "C-1")。代号与机器 + 收据只存在于折叠的技术回执和 artifacts 链接里。 +- **可操作入口只保留一处**。批准/拒绝等按钮只存在于宿主投影的审批卡;汇报区严格只读, + 需要人决策时只做文字指引("见上方审批面板"),不渲染第二个按钮。 + +## 最高优先级:快速反馈迭代(用户要求,不可违背) + +1. **不要主动编译**:只有用户明确要求 agent 编译时,才进行构建/编译。被要求编译时, + 构建/编译一律放后台并行执行(background job),不要在等待期间空转;优先完成不依赖 + 编译结果的独立工作。 +2. **修改代码之后不运行测试或预检查**:默认不跑 `cargo test`、`pnpm test`、 + thin-client / 契约测试、`cargo check`、`pnpm run type-check:web` 等。只有用户 + **明确要求检查或测试**时才运行;用户只说“编译”代表直接产出可运行 exe,不包含预检查。 + - 允许的秒级自检:`node --check ui.js` / `node --check worker.js`、 + 肉眼确认 JSON 合法、`git diff --check`。这些不是测试。 +3. **改完尽快交付可见结果**:每次修改后,以最快路径让用户看到效果并等待反馈; + 但只有用户明确要求编译时,才执行以下编译与重启步骤: + - 纯 UI(`index.html` / `style.css` / `ui.js`):批量做完一轮修改 → + `node --check` → 用户要求编译后再单次重新编译 Desktop 二进制 → 重启应用; + - Rust 宿主行为:完成一批修改 → 用户要求编译后直接单次 + `cargo build -p openbitfun-desktop --bin openbitfun-desktop`(统一配方,见 README + 「统一构建配方」,勿混用不同 profile 环境变量)→ 重启应用。最终 build 本身就是 + Rust 编译验证,不要在它前面重复跑 `cargo check`。 +4. **先收集反馈,再继续下一轮**:交付可见结果后停下,等用户反馈;不要自行 + 连锁扩展改动范围("快速迭代"≠"一次改很多")。 + +## 分层最小动作(速查) + +| 修改文件 | 用户要求编译前可做的最小动作 | 用户明确要求编译后(何时才编译 Desktop) | +|---|---|---| +| `index.html` / `style.css` / `ui.js` | 连续批量编辑;`node --check ui.js` | 单次重新编译 Desktop 二进制,然后重启应用 | +| `worker.js` | `node --check worker.js` | 重新编译并重启 Worker | +| `meta.json` / `esm_dependencies.json` | 检查 JSON 与权限差异 | 编译并 reseed | +| `src/crates/contracts/product-domains/src/miniapp/loopx/**` | 完成一轮修改,等待用户要求;不跑 Cargo 预检查 | 单次构建 Desktop binary,然后重启应用 | +| `src/crates/services/services-integrations/src/miniapp/loopx_*.rs` | 完成一轮修改,等待用户要求;不跑 Cargo 预检查 | 单次构建 Desktop binary,然后重启应用 | +| `src/crates/assembly/core/src/miniapp/loopx/**` | 完成一轮修改,等待用户要求;不跑 Cargo 预检查 | 单次构建 Desktop binary,然后重启应用 | +| `scripts/build-loopx.mjs` / LoopX pin | 不做动作,等待用户要求 | `pnpm run build:loopx`(只在用户要求且 sidecar/pin 变化时) | + +> 编译总原则:上表只是「用户明确要求编译时的最小动作」,不代表 agent 可以自行触发编译; +> 默认只有用户明确要求 agent 编译时才编译。 + +注:宿主目录见 `src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx` 的 +上一级(`../../../../../..` 之外的 Rust 目录),完整说明在 `README.md`。 + +## 快速循环约定 + +- 通常保持 Web UI Vite 常驻(`pnpm --dir src/web-ui dev`,端口 1422),Frontend + 改动走 HMR;MiniApp 资源因 `include_str!` 内嵌,仍需编译才能进二进制。仅在下方 + Windows 低内存规则触发时临时暂停,构建结束后必须恢复。 +- 2026-08-26 失败复盘:只重新编译并启动 `target/debug/openbitfun-desktop.exe`,但没有确认 + Web UI Vite 已恢复,会让 Desktop WebView 打开 `localhost` 后显示 + `ERR_CONNECTION_REFUSED`。启动或重启 Desktop 前必须确认 1422 已监听,并做一次 HTTP + 探测;若未监听,先后台启动 `pnpm --dir src/web-ui dev --host 127.0.0.1`,确认 + `http://127.0.0.1:1422/` 返回 200 后再启动 Desktop。 +- 每次重新编译前**先停掉正在运行的 `openbitfun-desktop.exe`**,避免两个实例抢占 + 同一个 AppData / reseed 目录。 +- 启动直接用刚编译的 `target/debug/openbitfun-desktop.exe`(Vite 保持运行), + **不要**用 `pnpm run desktop:dev` 反复启停做 UI 微调。 +- 编译、启动一律放后台 job;新二进制会按内容哈希自动 reseed `compiled.html`。 +- `src/web-ui/**` 改动由常驻 Vite HMR 直接生效;快速反馈流程不要追加 + `pnpm run type-check:web`,也不要因此重新编译 Rust。只有内嵌 MiniApp source 或 Rust + 发生变化时才需要 Desktop binary build。 + +## 编译影响面约束(写代码前执行) + +1. **先判断 Cargo 影响链再编辑**:Rust 的增量单位主要是 crate,不是单个文件。修改 + `product-domains`、`services-integrations` 或 `openbitfun-core` 任一项都会让其下游重新编译; + 同一轮同时触及三者会形成 `contracts → services/core → desktop` 的大范围重编。编辑前 + 必须列出准备触及的 crate,并确认每一层都是当前可见结果所必需。 +2. **UI 问题不扩散到 Rust**:文案、布局、日志展示和交互只改内嵌 MiniApp source; + `src/web-ui/**` 问题只改 Web UI 并走 Vite HMR。不要为了方便把纯展示逻辑放进 Rust, + 也不要因为 Web UI 改动重新编译 Desktop。 +3. **LoopX 私有行为留在最窄 owner**:LoopX 专用投影、去抖、日志摘要和调度逻辑优先 + 留在 `src/crates/assembly/core/src/miniapp/loopx/**`。不要顺手修改全局配置、共享事件、 + runtime、Cargo features 或 manifest;只有真实稳定合同属于下层 owner 时才向下修改, + 不得为编译速度破坏正确架构边界。 +4. **主流程修复与非阻塞改进分轮**:当前问题能在一个 owner 内闭环时,不把 prompt + 润色、共享重构、通用清理或另一层的体验优化塞进同一次真机反馈 build。记录为下一轮, + 等用户看到主流程效果后再决定是否做。 +5. **不制造无关源码变更**:不格式化未触及的 Rust 文件,不调整 Cargo.toml/features, + 不移动模块,不做与当前问题无关的重命名。Cargo 按内容哈希判断脏单元,任何共享源码 + 变化都可能扩大下游重编。 +6. **批量编辑,一次 binary build**:在不编译的状态下完成同一影响链的全部必要修改, + 秒级检查后只构建一次。不要为了逐文件确认而在中间启动 Cargo。 +7. **构建前向用户说明预期影响面**:若不可避免地同时触及多个广泛 crate,编译前简短 + 说明为什么无法保持局部,以及预计会重编哪些层。长期需要进一步提速时,应评审把 + LoopX Desktop wiring / embedded assets 从大 crate 拆到更窄的产品 owner;不要临时用 + 错误依赖方向规避编译。 + +## Windows Desktop 编译防重跑规范(2026-08-26 失败复盘) + +本机为 16 GB Windows。一次失败流程中,已有的不同 profile `cargo check` 长时间占锁, +随后另一条 `cargo test` 抢占同一 target;默认并发的正式 build 又多次在没有 Rust 诊断时 +退出。实测单个 `openbitfun-core` `rustc` 工作集接近 5 GB。为避免等待、冷重编和内存峰值, +用户明确要求编译时必须遵守: + +1. **编译前双重验锁**:先查看所有 `cargo` / `rustc` 的 PID、命令行和开始时间;已有 + Cargo 时不得再启动 check、test 或 build,也不得终止不属于当前任务的进程。等待其 + 结束,并在正式 build 前立即复查一次,确认 target 无竞争者。 +2. **极速反馈只运行最终 build**:用户要求“编译看效果”时,不运行 `cargo check`、 + `cargo test`、Web UI type-check 或任何前置构建。它们重复解析/编译依赖,却不产生用户 + 要试用的 exe。若用户另外明确要求某个检查,该检查也必须与 README 统一配方同指纹 + (仓库 `[profile.dev]` 基线,不设置任何 `CARGO_PROFILE_DEV_*` 覆盖),禁止引入 + profile 覆盖污染增量指纹。 +3. **本机强制单并发**:设置 `CARGO_BUILD_JOBS=1`。该变量只限制 + 同时运行的 rustc 数量,不改变 Cargo 指纹;不要用默认并发或 `-j 2` 反复碰内存 + 上限。统一命令为(仓库 `[profile.dev]` 基线指纹,无任何 profile 环境变量): + + ```powershell + $env:CARGO_BUILD_JOBS = "1" + cargo build -p openbitfun-desktop --bin openbitfun-desktop + ``` + + 指定 `--bin openbitfun-desktop` 用于明确只请求 Desktop binary target,但当前 package 的 + binary 依赖同包 lib,而 `[lib]` 同时声明 `staticlib`、`cdylib`、`rlib`;Cargo 仍会 + 在一次 lib rustc 中生成三种 crate-type,并链接约 30 MB 的 + `openbitfun_desktop_lib.dll`。不要宣称 `--bin` 已省掉该阶段。真正移除它需要把移动端/FFI + wrapper 与 Desktop 使用的 rlib 拆成不同 package/target,必须作为独立架构改动评审。 + + 构建前同时检查系统可用提交空间;低于 2 GB,或单个 rustc 运行期间降到 1 GB 左右时, + 可以临时停止**仅属于本仓库**的 Vite 进程链,构建结束后用原命令恢复。2026-08-26 + 实测暂停 Vite 将可用提交空间从约 650 MB 恢复到约 1.8 GB,使最终链接完成。不得为 + 编译关闭其他用户应用、Codex 进程或无关服务。 + +4. **只保留一个可追踪的后台 build**:记录后台 job/session、构建开始时间和输出日志, + 持续轮询同一个句柄直到退出;不得因为暂时没有输出而重复启动。正常构建不要用 + `-vv`,只有无诊断退出时才用它定位最后一个 rustc 命令。 +5. **成功必须有四项证据**:Cargo 退出码为 0;输出含 `Finished`; + `target/debug/openbitfun-desktop.exe` 的 `LastWriteTime` 晚于本次构建触及的所有 + Rust/内嵌源文件(指纹未变化时 Cargo 允许不重链,此时以 exe 晚于全部源文件 + mtime 为准);Cargo/rustc 已全部退出。缺一项都视为构建失败,不得启动旧 exe + 冒充新版本。 +6. **启动前后都核对进程**:build 前停止全部 `openbitfun-desktop.exe` 并确认已经退出, + 防止最终链接或 AppData reseed 冲突;仅在上述成功证据齐全后后台启动新 exe,再核对 + 进程 `StartTime` 与路径。构建期间若 Desktop 被其他入口重新拉起,先停掉它再等待链接。 +7. **失败先诊断,不盲目重跑**:先检查日志尾部、竞争 Cargo 命令、exe 时间戳和系统 + 可用内存。无 `error:` / 无 `Finished` 且 exe 时间戳未变,说明没有产出新应用;不要 + 宣称编译成功,也不要继续运行旧二进制。测试仍只在用户明确要求时运行。 +8. **2026-08-26 极速反馈复盘**:一次主流程修复在最终 build 前运行了两个 + `cargo check`,之后又运行 Web UI type-check;它们分别额外消耗约 1 分 44 秒和 + 1 分 50 秒,且没有让用户更早看到效果。后续把所有修改集中完成后只运行一次 + `cargo build -p openbitfun-desktop --bin openbitfun-desktop`。运行中新发现的纯 Web UI 问题 + 走 Vite HMR 修复,不再触发第二次 Rust build。 + +9. **2026-09-02 “全部已中止”复盘(跨版本数据根冲突 + 注意力契约)**:LoopX 任务 + 集体进入 recovery,错误为 `Agent coordination database schema 2 is newer than + supported schema 1`。根因不是 LoopX 工作流,而是**数据根跨构建共享**: + `coordination.sqlite` 位于 `BITFUN_USER_ROOT`(默认 `%APPDATA%/bitfun/`), + 安装版 / worktree 构建 / dev 构建全部写同一个库;带 swarm 表的新构建把它升到 + schema 2,只认 schema 1 的 dev 构建按设计拒绝打开,于是每个 LoopX 回合创建 + Agent 会话都失败。修复必须做在结构上而不是 case 上: + - dev 启动(`scripts/dev.cjs`)已设置独立 `BITFUN_USER_ROOT` + (`%APPDATA%/com.bitfun.desktop.dev/bitfun`),跨构建 schema 冲突从此结构性 + 不可能;diagnose “已中止/恢复”类问题第一步永远是 + `PRAGMA user_version` + 任务快照的 `task.error`。 + - `apply_goal_projection` 在权威 Goal 投影恢复健康(非 recovery/failed)时清除 + 陈旧 `task.error`,避免旧环境错误挂在已恢复任务上误导排查。 + - **注意力契约**:人类注意力是稀缺资源。无头 agent 会话(sessionKind + 'miniapp')的每轮完成永不发系统通知(`dialogCompletionNotifyPolicy` 显式 + 排除);OS 通知只在 owner 决策点发出(user gate 出现时经 + `notifications.system`)。不要用命令黑名单去拦 Agent 的弹窗能力,也不要在 + 宿主侧发明停滞/回合数启发式门禁——钻牛角尖防护复用 LoopX 自有机制 + (stall observation → autonomous replan obligation → 持续卡住 pause; + agent 主动 user_gate),外部写入(创建 PR)保持天然 owner 审批。 + +10. **2026-09-02 envelope 超预算复盘(升级 loopx 版本不解决)**:turn plan 返回 + `route=contract_error`(`turn_envelope.compaction.within_budget=false`)时, + Goal 无法被计划,且 **v0.5.1 / v0.5.2 / v0.5.3 / main 行为完全一致**——实测 + 同一 goal 在全部版本下 envelope 均超 8192 字节预算(8279/8192,仅超 87 字节, + 源 46KB)。压缩增强(#2190 text_ref 去重)已在 pin 内仍不够; + `todo archive-completed` 不影响 envelope。宿主正确姿势是**响亮降级**: + `LoopxCliGoalSnapshot.envelope_over_budget` → Queued + 诊断事件 + 退避重试, + 绝不 fail 进 recovery 死循环。立即解套手段:`todo update --text` 精简超长 + todo 文本(实测 8279→7762,回到预算内)。根治需上游:提高 envelope 预算、 + 渐进截断 recommended_action/suggested_actions 长文本、或提供 goal compact + 自愈命令(loopx 仓库议题)。 + + +## 禁止事项 + +- 不要主动编译:用户没有明确要求时,不执行 `cargo check`、`cargo build`、 + `pnpm run build:loopx` 等任何构建/编译动作。 +- 用户只要求“编译看效果”时,不要自行追加 `cargo check`、`pnpm run type-check:web` + 或测试;最终 binary build 是唯一允许的编译动作。 +- 不要在每次代码改动后主动跑测试套件(见上)。 +- 不要直接修改 `%APPDATA%/bitfun/data/miniapps/builtin-bitfun-loopx/**` 当源码。 +- 不要用 `git add .` 或把运行目录/生成物(`compiled.html`、`~/.bitfun/bitfun-loopx/**`)提交。 diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/README.md b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/README.md new file mode 100644 index 0000000000..ce28f7e567 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/README.md @@ -0,0 +1,356 @@ +# bitfun-loopx(内置 MiniApp) + +内置版 **bitfun-loopx**:粘贴 GitHub Issue 链接,由 OpenBitFun 宿主 Agent 驱动本机 +LoopX 引擎持续修复,心跳调度、人工审批、中途插话。 + +本目录就是该内置 MiniApp 的**唯一权威源码**,不依赖任何外部仓库快照。五件套 +`index.html` / `style.css` / `ui.js` / `worker.js` / `esm_dependencies.json` 由 +`src/crates/contracts/product-domains/src/miniapp/builtin.rs` 的 `BUILTIN_APPS` +以 `include_str!` 嵌入二进制,注册 id 为 `builtin-bitfun-loopx`(同文件契约测试 +含 id 顺序断言);`meta.json` 的权限与注册条目保持一致。 + +## 高效修改流程 + +### 先判断改动属于哪一层 + +| 修改文件 | 改动类型 | 开发期最小动作 | 何时需要 Desktop 编译 | +|---|---|---|---| +| `index.html` / `style.css` | MiniApp 结构、布局、样式 | 集中完成一批修改,不跑 Rust 检查 | 真机验证前编译一次 | +| `ui.js` | MiniApp 交互和宿主桥调用 | `node --check <本目录>/ui.js` | 真机验证前编译一次 | +| `worker.js` | 兼容占位(必须保持无业务逻辑) | `node --check <本目录>/worker.js` | 不单独启动 Worker | +| `meta.json` | 权限、版本、运行方式 | 检查 JSON 和权限差异 | 必须编译并 reseed | +| `esm_dependencies.json` | 浏览器 ESM 依赖 | 检查 JSON;确认 import map | 必须编译并 reseed | +| `src/crates/contracts/product-domains/src/miniapp/loopx/**` | LoopX DTO、状态、端口、纯策略 | 集中完成修改,不跑 Cargo 预检查 | 联调前单次编译 binary | +| `src/crates/services/services-integrations/src/miniapp/loopx_*.rs` | GitHub、Git workspace、CLI 等宿主服务 | 集中完成修改,不跑 Cargo 预检查 | 联调前单次编译 binary | +| `src/crates/assembly/core/src/miniapp/loopx/**` | controller、Agent 编排、持久状态 | 集中完成修改,不跑 Cargo 预检查 | 联调前单次编译 binary | +| `scripts/build-loopx.mjs` 或 LoopX pin | 随包 sidecar | `pnpm run build:loopx` | 只在 sidecar/pin 变化时 | + +这里的 `<本目录>` 是: + +```text +src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx +``` + +### 纯 UI 快速循环 + +内置 MiniApp 资源由 `include_str!` 嵌入 Desktop 二进制。当前没有一个既能热替换 +source、又能继续通过受信任 built-in 校验的免编译入口。因此正确的快速循环是 +“多次编辑,一次编译”,而不是每保存一次就启动 Desktop 构建。 + +1. 保持 Web UI Vite 常驻;没有运行时才启动: + + ```bash + pnpm --dir src/web-ui dev + ``` + +2. 连续修改 `index.html`、`style.css`、`ui.js`,先把一轮视觉交互做完整。 +3. JS 变化只做秒级语法检查: + + ```bash + node --check src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/ui.js + ``` + +4. 需要在真实 MiniApp bridge 中验收时,停止正在运行的 `openbitfun-desktop`,只构建 + 一次 Desktop executable(配方见下方「统一构建配方」,不要换用别的环境变量): + + ```bash + cargo build -p openbitfun-desktop --bin openbitfun-desktop + ``` + +5. 保持 Vite 不退出,直接启动刚生成的 executable: + + ```text + target/debug/openbitfun-desktop.exe + ``` + + > Debug 构建直接启动时**默认使用隔离 dev 数据根** + > `%APPDATA%/com.openbitfun.desktop.dev/openbitfun`(与 `scripts/dev.cjs` 一致),与安装 + > 版互不可见,跨构建 schema 冲突从结构上不可能;需要显式覆盖时设置 + > `OPENBITFUN_USER_ROOT`。Release 构建仍使用默认根 `%APPDATA%/openbitfun/`。 + +6. 新二进制启动后会根据 built-in 内容哈希自动 reseed,并重新生成 + `compiled.html`。此时再进入 LoopX MiniApp 验收。 + +快速循环中不要使用 `pnpm run desktop:dev` 反复启停。该入口会执行资源准备、 +Cargo watch 和 target GC;在 Windows 上可能导致本来可增量的 UI 修改退化为大范围 +冷编译。它适合需要持续修改 Desktop Rust 的完整开发会话,不适合只调 MiniApp CSS。 + +### 为什么不能直接改 AppData + +不要把下面的运行目录当成源码: + +```text +%APPDATA%/openbitfun/data/miniapps/builtin-bitfun-loopx/source/** +%APPDATA%/openbitfun/data/miniapps/builtin-bitfun-loopx/compiled.html +``` + +- `compiled.html` 是生成物,刷新、recompile 或重启后会被覆盖; +- `source/**` 必须与二进制内嵌的 built-in source 一致;直接修改会使 + `builtin_source_matches` 失败,受信任的 LoopX controller bridge 将被禁用; +- `miniapp_sync_from_fs` 适用于普通 MiniApp 或明确的本地定制流程,不能作为受信任 + built-in LoopX 的临时热补丁; +- 只改运行目录会造成“代码已经改了,但界面仍旧”或“界面变了,但 bridge 不可用”。 + +唯一权威源码始终是本 README 所在目录。 + +### 宿主行为修改 + +GitHub 认证/限流、Git workspace、环境预检、Agent 会话、任务恢复和持久状态不是 +纯 MiniApp UI。这些改动需要修改对应 Rust owner。快速真机反馈不跑前置 +`cargo check`;最终 binary build 会完成同一份 Rust 编译验证并直接产生可试用结果。 +不要同时运行多个 Cargo 命令,也不要在 `desktop:dev` 正在自动编译时再手动启动 +Cargo;它们会争用同一个 target 锁。 + +完成所有宿主改动后再执行一次: + +```bash +cargo build -p openbitfun-desktop --bin openbitfun-desktop +``` + +### 统一构建配方(重要) + +`openbitfun-desktop` 的默认 features 为空,`--no-default-features` 没有实际差异。增量 +指纹的权威来源是仓库 `[profile.dev]` 基线:`Cargo.toml` 固定 +`debug = "line-tables-only"`,dev profile 默认 `incremental=true`、 +`codegen-units=256`。**任何 `CARGO_PROFILE_DEV_*` 覆盖都会改变指纹并触发整棵 +依赖树重编**(2026-09-03 实测:裸 `cargo run` 与旧 `DEBUG=0` 配方互踩,同一天内 +连续两次全量冷编译)。统一配方就是仓库基线本身,不再设置任何 profile 环境变量: + +```powershell +$env:CARGO_BUILD_JOBS = "1" +cargo build -p openbitfun-desktop --bin openbitfun-desktop +``` + +`CARGO_BUILD_JOBS=1` 只限制并发 rustc 数量(16 GB 内存约束),不改变指纹。 +`scripts/dev.cjs` 的快速重建默认值已与该基线对齐 +(`DEBUG=line-tables-only`、`INCREMENTAL=true`、`CODEGEN_UNITS=256`),手动 build +与 `desktop:dev` / `desktop-preview` 共享同一指纹,互相增量。 + +- **不要**设置 `CARGO_PROFILE_DEV_DEBUG=0` 或其他 profile 覆盖;需要断点调试时按 + `Cargo.toml` 注释临时使用 `CARGO_PROFILE_DEV_DEBUG=2`(接受一次全量重编,用完 + 清除该环境变量再回到基线); +- 快速反馈不要在 build 前追加 `cargo check`、测试或 Web UI type-check。指定 + `--bin openbitfun-desktop` 明确请求 binary target;但当前同包 lib 同时声明 + `staticlib/cdylib/rlib`,Cargo 仍会生成并链接 `openbitfun_desktop_lib.dll`。要去掉该阶段, + 需要把移动端/FFI wrapper 与 Desktop rlib 拆成独立 package/target; +- `src/web-ui/**` 由 Vite HMR 直接刷新,不需要 Rust build;内嵌 MiniApp source + 仍须一次 binary build 才能 reseed; +- 需要同时持续改 Desktop Rust 时,改用 `pnpm run desktop:dev` 完整会话,不要在同一 + target 上混跑; +- 装 sccache 可进一步让配方/feature 切换也不触发全量重编(可选优化)。 + +### 提交或发布前 + +开发期不需要 bump version,内容哈希变化会触发 reseed。发布时才做以下动作: + +1. 同步增加 `builtin.rs` 中 `BUILTIN_APPS` 的 version 和 `meta.json` 的 version; +2. 运行聚焦 built-in 契约测试: + + ```bash + cargo test -p bitfun-product-domains --features product-full builtin_miniapp + ``` + +3. sidecar pin 或构建脚本有变化时运行: + + ```bash + pnpm run build:loopx + ``` + +4. 需要完整 Desktop 构建产物时使用仓库入口: + + ```bash + pnpm run desktop:build:fast + ``` + +规范依据:`MiniApp/Skills/miniapp-dev/SKILL.md` 的「内置小应用(builtin/assets/*) +维护规范」。 + +## 内置更新行为 + +- 内容哈希变化(无论 version 是否 bump)都会在下次启动时 reseed:用户机器上的 + 源文件被覆盖为最新内置版本; +- 用户对源文件的**手动本地修改会被覆盖**,除非该应用处于"本地定制(local + override)"状态——那时只记录"有可用更新"通知(可拒绝),不改动本地内容; +- `storage.json`(设置、GitHub Token、goal↔session 映射等)跨 reseed 始终保留。 + +## 权限与信任模型 + +本应用遵守 MiniApp V2 无 Node 规范:`meta.json` 明确设置 `node.enabled=false`, +`worker.js` 只有空兼容导出,UI 不执行 shell、文件或网络原语。普通和市场 MiniApp 的 +`window.app` 公共 API 中没有 LoopX 控制器。 + +编译器只为 id 精确匹配 `builtin-bitfun-loopx` 的非 strict 构建注入私有 +`app.loopx` namespace;Web UI 与 Desktop 在每次调用时继续校验 active scope、原始 +built-in source、非本地覆盖和本地执行域。伪造 id、draft、市场包或修改后的内置源码 +都不能取得该控制器。这个 namespace 是产品私有扩展,不得被其他 MiniApp 使用或模拟。 + +## 平台支持 + +- Desktop 安装包携带固定版本的 LoopX sidecar;资源缺失或系统版本不匹配时,用户可从 + 环境卡片显式触发安装:宿主从官方 GitHub 仓库 clone 固定 `v0.5.1`/commit 到 + OpenBitFun 管理目录并用 Python 3.11+ 直接运行源码。不会覆盖系统 `loopx`,也不会修改 + 用户的全局 Python 环境;没有 Python/Git 时返回明确前置条件错误。安装 action 只负责 + 持久化进行中状态并立即返回,下载在宿主后台执行;clone 使用 blobless sparse checkout, + 只检出 `loopx/` 与必要元数据/许可证,完成或失败都通过环境事件回推 UI。 +- Git workspace、GitHub intake、进程树与 sidecar 探测由 Rust service owner 实现, + 不进入 UI 或 Worker。 +- 当前只支持本地 Desktop workspace。Remote Workspace、Peer Device、Remote Control + 和 Detached Dispatch 均返回明确 unsupported,不静默回退到控制端本机。 + +## 设计假设 + +- `LoopxController` 是进程级 host driver,不依赖 MiniApp iframe 生命周期;关闭或 + 重开界面不会创建第二套心跳。UI 通过 cursor replay 恢复事件。 +- Desktop 待机恢复由 MiniApp 的可见性/焦点/时钟间隙检测触发幂等 attach;可信私有 + attach 会让宿主刷新环境与非运行 Goal 投影,但保留仍可恢复的单一 Agent turn,避免 + 伪造失败或重复启动。活动任务长时间没有事件时 UI 只做有界快照重取。 +- 普通 UI attach 不重复 inspect `WaitingForUser`、终态或显式恢复态 Goal;这些状态由 + 宿主审批/恢复动作直接推进,仅在真实待机恢复的 force reconciliation 中重新向 CLI + 对账。这样等待审批不会每 30 秒生成一个可能超时的 sidecar 进程。 +- Issue 列表与 issue 视图是一级工作区:右侧 issue 视图永远对应当前选中任务,未选中时自动跟随正在运行的任务并显示跟随横幅;点击左侧任务即固定查看该 Issue,右侧内容与左侧选中严格一一对应。issue 视图自上而下集中展示:任务头(标题、状态、操作、GitHub 链接)、审批面板、当前阶段(五阶段总结 + 当前动作)、最新进展(durable 的最后回合总结 + 事实 chips:工作区路径、回合、结算回执、产出物、错误)、原始 Issue 描述(可折叠)和合并时间线(宿主事件与模型实时输出按轮次交错,准备期/排队期不再空白)。审批门禁同时投影为持久顶部提示,但只负责提醒与跳转;批准/拒绝只在 issue 视图内提交,提交后以任务 pending 状态等待宿主确认,不把 CLI 往返延迟表现成按钮卡死。 +- 桌面默认保持“任务 / Issue 详情 / 运行时间线”三列,只有右侧 issue workspace 小于 + 780 CSS px 时才把详情和时间线上下堆叠。选中任务后只读刷新一次 GitHub metadata, + 优先显示当前的 Unicode-safe 600 字符摘要;网络不可用时回退到持久任务快照。 +- `pending_gate_id/message/action_kind` 属于任务快照的持久投影,审批按钮不依赖可能被 + 截断的历史事件回放。升级前的 `WaitingForUser` 记录若缺少该投影,普通 attach 只做 + 一次 CLI reconciliation 补齐,之后重新进入等待态免轮询路径。 +- 每轮 turn 由专用 `LoopxAgentPort` 通过通用 `AgentSubmissionPort` 启动临时 OpenBitFun + Agent session。通用 Agent loop 不包含 LoopX 分支;`LoopxCliPort` 用只读 `turn plan` + 做状态对账,真正执行前只调用一次 `quota should-run --turn-envelope`,并把其中的 + selected todo、boundary、required reads、execution policy 和 writeback contract + 投影给 Agent。Agent 技术能力由 assembly 显式声明,services adapter 只负责协议翻译; + permission grant 始终是另一条独立边界。 +- Intake 展示的五项标准 scope 是无头 issue-fix Agent 完成工作的必需集合。用户明确确认 + 全部 scope 后,host 才以 `auto_approve` 运行该 transient turn,避免在没有通用工具审批卡的 + MiniApp 中创建无人能回答的 permission request。发布、公开评论、PR、合并和生产操作不可在 + intake 预授权,仍由 LoopX typed user gate 要求 owner 决策。缺少任一必需 scope 时在创建阶段 + 明确拒绝,不启动一个会隐形等待权限的 Agent turn。 +- LoopX registry 是 goal/todo/gate/quota/settlement 的唯一权威。OpenBitFun 持久化的 + `LoopxTaskState` 只描述 workspace、session、取消和恢复等 host-job 生命周期,并保存 + 最近一次只读 `goalState` 投影;启动环境检查和 UI attach 都会向 CLI 对账。 +- OpenBitFun runner 在调用 Agent 前执行新鲜 `quota should-run` guard;Agent 只推进一个有界 + selected todo,并通过真实工具结果验证后按 LoopX writeback contract 写回。宿主不从 + 对话文本或 Agent 进程退出码推断进展,只读核验与 goal、agent、Turn 和 todo / + autonomous-replan binding 匹配的 durable writeback 与 quota receipt。缺少任何一项都进入 + 显式 recovery;宿主不补写 quota、不伪造 progress,也不启动 settlement-repair Agent。 +- 当前采用官方 mainstream cooperative runner 路径:Agent 负责按 packet 校验真实 + postcondition 并写回,宿主独立核验 durable writeback 与 quota receipt。由于 OpenBitFun 尚未 + 提供 task-specific validator port,本集成不宣称满足 experimental `turn run-once` 的 + typed result + independent validator qualification;引入该路径前必须先补齐稳定结果合同、 + 独立 validator 和 replay/resume 幂等证明。 +- 任务快照在结算前保存有界的最后 Agent 回合总结,供详情页展示分析、方案、产出和 + 下一步;该总结只是 UX 投影,不参与 Goal 状态、durable progress 或 settlement 判定。 + Subscriber 只聚合最后一个模型 round 的最新 attempt,防止中间工具回合或重试文本 + 混入最终总结。 +- **目标模型:一个 goal 只对应一个 issue/PR**(`goal_id_for` 生成 + `bfx-owner-repo-issue-N`,重试追加 `-attempt` 后缀),每个 item 有独立 + worktree 与 `.loopx/registry.json`;todo 是 **goal 内部** 的推进项/审批门禁 + (`todo add --goal-id` 强绑定单一 goal),不用一串 todo 把多个 issue 串在 + 一个 goal 下。依据:loopx v0.5.x 的 goal 是「单一 objective 的持续 turn 载体」, + quota/心跳/审批/结算都以 goal 为域,registry 本身支持多 goal 列表—— + 多 issue 的"批量管理"由本应用 task/batch 层聚合,不压平到 loopx goal。 +- **Custom Agent Runner 合同**:OpenBitFun 采用 LoopX 官方 mainstream 路径,不转发或改写 + Codex heartbeat prompt,也不在宿主中复制 LoopX CLI 教程。每次唤醒重新读取 fresh + TurnEnvelope,由 adapter 生成一段稳定 re-entry instruction,附带唯一 CLI prefix、 + registry、Turn identity 和本轮最小合同。上一轮对话摘要只用于 UI 展示,不回灌为 + 控制事实;项目 policy、todo 列表、cadence 和领域流程始终从 LoopX 当前状态读取。 +- **Goal 终局**:只有 LoopX 投影 `Complete` 或 `Archived` 时,宿主才把 host task + 收束为 Completed。若 LoopX 返回 `RunNow` 却没有开放 todo,宿主进入显式 recovery, + 保留 registry 与 worktree,绝不代写 `goal-lifecycle stop` 或伪造终局。 +- **心跳调度**:本应用维护一个统一的 task 调度循环(非每 issue 一个独立 + 定时器);`inspect_goal` 读取 LoopX cadence。当前 `v0.5.1` 的 `outer_controller` + profile 不返回数值间隔时,宿主使用明确的 60 秒兼容间隔;未来 packet 提供数值 hint + 时优先按 hint 重新排队。同一仓库的多个 goal 串行推进( + `active_repositories` + `schedule_next_for_repository`)。每次 durable settlement + 是公平轮转边界:有其他排队 Issue 时先让出仓库槽,不把当前 task 标记为 pending + 并在同一 worker 内自重入;轮到其他 Issue 结算后再回到当前 Goal。 +- **PR 生命周期监控投影**:PR 发布后 LoopX 用 `continuous_monitor` / + `issue_fix_pr_state_*_monitor` todo 继续持有 Goal,pr-lifecycle 的四种转移 + (runnable_successor / monitor_continuation / user_gate / no_followup)全部在 + pinned CLI 内决策。宿主不调用 `issue-fix pr-lifecycle`,也不压缩 maintainer + correction——两者都是 agent 轮内按 TurnEnvelope / packet 的职责。宿主只把 + turn plan envelope 的 selected todo 投影为任务快照 `currentTodo`(有界、非权威、 + Goal 终局清除),UI 据此把排队态区分为「PR 监控等待中」并展示下次检查时间, + 避免等待 CI/review 被误读为卡住。 +- **worktree 成本**:同仓库所有 task 共享一份裸仓库对象库 + (`//bare.git/`,首个 task `git clone --bare` 建立),每个 + task 用 `git worktree add -b bitfun-loopx/` 挂出独立工作区 + (`///`)。磁盘 ≈ 1 份对象库 + 各 task 的检出 + 文件;历史版本升级前创建的旧式独立克隆(每 task 一份完整 `.git`)仍可 + 正常复用,不强制迁移。归档会立即释放单个任务的磁盘:dispose 先 + `git worktree remove --force` 删除该 task 工作区,再按 + `git worktree list` 剩余条目数判断是否删除共享裸仓库(最后一个 worktree + 离开时整仓回收)。loopx 上游只 `connect` 已存在项目、不 clone,克隆策略 + 是宿主侧职责,改动需同步 `loopx_workspace.rs` 与克隆契约测试。 +- **依赖与构建缓存边界**:包管理器下载缓存(例如 Yarn Berry 全局 cache)和 Git 对象库 + 可以跨 task 复用;`node_modules`、构建目录和测试进程属于可变工作树状态,不在并行 + Issue 间直接共享,避免一个 Issue 的安装脚本或平台产物污染另一个 Issue。Agent 使用 + OpenBitFun 通用 Runtime 的工具与进程约束;LoopX adapter 不复制一套专用执行手册。 +- **全量清空**:重置会先把整个旧 workspace root 原子改名隔离,立即创建新的 + 活动 root,并搬回经过目录边界验证的 `bare.git` 对象缓存;旧 task Worktree + 在后台递归回收。这样不会复用脏工作树或失去 owner 的未结算修改,但下一次同仓库 + 任务不必重新下载完整 Git 历史。要继续已有修改应使用仓库级恢复,不应先重置。 +- **收敛防护复用 LoopX 自有机制(宿主不造轮子)**:宿主不合成任何停滞/ + 同-todo/回合数启发式门禁,也不在任务快照里维护第二套收敛计数。LoopX 自有的防护链 + 已经覆盖:stall observation 会让 + LoopX 向 agent 下达 autonomous replan obligation(连续卡住时强制重规划, + 持续不可修复则 pause 该 Goal 的心跳),agent 自己在需要 owner 决策时通过 + typed `user_gate` 上抛(例如外部写入前的 PR 审批门禁)。宿主只在 Goal 无开 + settlement 完全缺失或 Goal 投影自相矛盾时进入显式 recovery。审批文案 + 遵循“注意力税”原则:只有在真正需要人类决策(无法自行解决、或对外发布)时 + 才请求审批,且必须携带背景、已做工作、卡住原因与需要的决定;审批面板只展 + 示 gate 原始消息与分类后的后果说明,不伪造 issue 背景/影响描述。 +- **通知契约(宿主独占)**:OS 级 toast/通知由宿主统一管理。MiniApp 无头 Agent + 会话(sessionKind 'miniapp')的每轮完成被 + `dialogCompletionNotifyPolicy` 显式排除,不产生“任务完成”toast——注意力 + 只在 owner 决策点被请求:新 user gate 首次出现时由 MiniApp UI 通过 + `notifications.system` 桥发系统通知(meta.json 需 + `notifications.system: true`)。workspace 准备等过渡性事件不产生系统级 + 提醒。 +- 模型成本仍由用户在 OpenBitFun 侧管理;收敛门禁是控制权边界,不是费用估算器。 + +## loopx 依赖与合规 + +- **内置编译二进制(随安装包分发)**:打包流程(`scripts/desktop-tauri-build.mjs`, + 即 `pnpm run desktop:build*` 的 bundle 路径)会先执行 `scripts/build-loopx.mjs`: + 构建期拉取 pin `v0.5.1` 的 loopx 源码并用 PyInstaller 编译单文件二进制,随 + tauri `bundle.resources` 作为 sidecar 分发(`resources/loopx/`)。桌面宿主把 + 资源目录由 Desktop 启动 wiring 传给 `LoopxCliProcessAdapter`,探测时内置二进制 + 优先,用户机器零依赖。资源缺失时依次使用经 commit 校验的 OpenBitFun 托管源码、版本 + 完全匹配的系统命令;托管源码安装只由用户点击触发,不在启动时静默下载。 + 生成的 `resources/loopx/` 目录在 `.gitignore` 中,二进制不进仓库;`manifest.json` + 记录版本、commit、内容哈希与构建工具链。 +- **Apache-2.0 再分发义务**:loopx v0.5.1 为 Apache-2.0(Copyright 2026 LoopX + contributors)。`resources/loopx/` 随包携带上游 `LICENSE`、`NOTICE`、历史 + `LICENSE-MIT` 与 `TRADEMARKS.md`;运行时托管源码保留完整 checkout。 + `THIRD_PARTY_NOTICES.md` 已收录对应条目。名称按 loopx + [TRADEMARKS.md](https://github.com/huangruiteng/loopx/blob/main/TRADEMARKS.md) 描述性使用, + 本应用是第三方集成,非 LoopX 官方出品。 +- **运行期兜底**:内置二进制缺失时,优先使用 OpenBitFun 管理的固定 GitHub 源码;未安装 + 托管源码时才检查版本完全匹配的系统 `loopx`。任何版本或 schema 不一致都直接拒绝。 +- 本应用自身的 GitHub 凭据只存于本机应用存储(gh CLI 或粘贴的 PAT),不写入 git config。 + +## loopx 依赖升级 + +loopx 的随包二进制与按需托管源码都 pin 同一个经过验证的版本。**不要自动追新**: +只有在确实需要新版能力/修复时才升级。 + +1. **读 release notes / CHANGELOG**,重点确认 CLI 的 `--format json` 输出契约 + (`turn plan` / `quota should-run` / `history` / `todo` / `bootstrap`)没有破坏性变更; +2. **改 pin**:同步修改 `scripts/build-loopx.mjs` 与 `loopx_cli.rs` 的版本、tag 和源码 + commit 常量; +3. **本机冒烟**:重建 sidecar 后跑一个 issue 全流程(intake → turn → todo 审批 → + receipt 观察)验证 JSON 契约; +4. **回滚**:恢复上述两个 pin 并重建 sidecar;已持久化 host job 和 LoopX registry + 均保留,不删除用户工作区; +5. **随发布提交**:pin 变更与 `version` bump 一起进发布版(发布版自带的 + sidecar 二进制由打包构建重新编译)。 + +**升级经验(首批开发踩坑记录)**: + +- runner 的执行 packet 必须来自一次新鲜 `quota should-run --turn-envelope`,并由宿主绑定 + 稳定 Turn id;`turn plan` 只用于无副作用状态投影,Agent 不能自行生成或从自然语言恢复 + Turn identity; +- sidecar JSON stdout 与进程 stderr 必须分流,stdout 只接受一个结构化文档; +- 不能通过自然语言或进程退出码推断结算成功,只接受 LoopX history/receipt 中与 + goal、agent、todo 和 Turn id 全部匹配的持久证据。 diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/esm_dependencies.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/esm_dependencies.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/esm_dependencies.json @@ -0,0 +1 @@ +[] diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/index.html b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/index.html new file mode 100644 index 0000000000..eb2b981d81 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/index.html @@ -0,0 +1,359 @@ + + + + + + LoopX + + + + +
+
+
+ +
+ LoopX + 正在连接宿主 +
+
+ +
+ + + + + + 模型 + + + +
+ +
+
+ + + 环境 + -- + +
+
+ +
+
+

核心环境

+ 必需 +
+
+
+
+
+

增强能力

+ 可选 +
+
+
+ +
+
+ +
+
+ + + + + + +
+ + + + +
+ + + + + +
+
+
+ + +
+
+
+ 确认任务 +

+
+ +
+
+
+
仓库
--
+
工作区
--
+
模型
--
+
图片能力
--
+
+
+
+

Issue / PR

+ 0 + +
+
+
+
+
+

本次权限

+ 逐项授权 +
+
+
+ +
+
+ + +
+
+
+ + +
+
+
+ 新尝试 +

已有终态任务

+
+
+
+

+
+
+ + +
+
+
+ + +
+
+
+ 批量操作 +

继续此仓库的任务

+
+
+
+

+
+
+ + +
+
+
+ + +
+
+
+ 危险操作 +

清空并重新开始

+
+
+
+

+

模型配置、GitHub 登录和 MiniApp 设置会保留。

+
+
+ + +
+
+
+ + + + diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/meta.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/meta.json new file mode 100644 index 0000000000..12fc537e5e --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/meta.json @@ -0,0 +1,49 @@ +{ + "id": "builtin-bitfun-loopx", + "name": "bitfun-loopx", + "description": "bitfun-loopx:提交 GitHub Issue 或 PR,由 BitFun 宿主持久调度 LoopX,并集中查看环境、审批与模型实时输出。", + "icon": "rocket", + "category": "developer", + "tags": [ + "loopx", + "agent", + "scheduler", + "monitor" + ], + "version": 15, + "created_at": 0, + "updated_at": 0, + "permissions": { + "node": { + "enabled": false + }, + "notifications": { + "system": true + } + }, + "ai_context": null, + "i18n": { + "locales": { + "zh-CN": { + "name": "bitfun-loopx", + "description": "bitfun-loopx:提交 GitHub Issue 或 PR,由 BitFun 宿主持久调度 LoopX,并集中查看环境、审批与模型实时输出。", + "tags": [ + "loopx", + "智能体", + "调度", + "监控" + ] + }, + "en-US": { + "name": "bitfun-loopx", + "description": "bitfun-loopx: submit a GitHub issue or PR, let the BitFun host schedule LoopX durably, and monitor environment, approvals, and live model output in one place.", + "tags": [ + "loopx", + "agent", + "scheduler", + "monitor" + ] + } + } + } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/style.css b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/style.css new file mode 100644 index 0000000000..ebafdcb1e7 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/style.css @@ -0,0 +1,1203 @@ +*, *::before, *::after { box-sizing: border-box; } + +:root { + color-scheme: dark; + --lx-bg: var(--openbitfun-bg, #111316); + --lx-surface: var(--openbitfun-bg-secondary, #181b20); + --lx-elevated: var(--openbitfun-bg-elevated, #20242a); + --lx-muted-surface: var(--openbitfun-bg-tertiary, #15181c); + --lx-element: var(--openbitfun-element-bg, rgba(255, 255, 255, .055)); + --lx-hover: var(--openbitfun-element-hover, rgba(255, 255, 255, .085)); + --lx-border: var(--openbitfun-border-subtle, rgba(255, 255, 255, .10)); + --lx-border-strong: var(--openbitfun-border, rgba(255, 255, 255, .17)); + --lx-text: var(--openbitfun-text, #eef0f3); + --lx-text-soft: var(--openbitfun-text-secondary, #b7bbc3); + --lx-text-muted: var(--openbitfun-text-muted, #858b95); + --lx-accent: var(--openbitfun-accent, #5b8def); + --lx-accent-hover: var(--openbitfun-accent-hover, #477de8); + --lx-success: var(--openbitfun-success, #35b779); + --lx-warning: var(--openbitfun-warning, #e7a83c); + --lx-error: var(--openbitfun-error, #e45b63); + --lx-info: var(--openbitfun-info, #42a5c6); + --lx-radius: min(var(--openbitfun-radius, 8px), 8px); + --lx-font: var(--openbitfun-font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); + --lx-mono: var(--openbitfun-font-mono, "Cascadia Mono", Consolas, monospace); +} + +[data-bf-appearance-mode="light"], [data-openbitfun-appearance-mode="light"] { + color-scheme: light; + --lx-bg: var(--openbitfun-bg, #f5f6f8); + --lx-surface: var(--openbitfun-bg-secondary, #ffffff); + --lx-elevated: var(--openbitfun-bg-elevated, #ffffff); + --lx-muted-surface: var(--openbitfun-bg-tertiary, #eef0f3); + --lx-element: var(--openbitfun-element-bg, rgba(18, 24, 34, .045)); + --lx-hover: var(--openbitfun-element-hover, rgba(18, 24, 34, .07)); + --lx-border: var(--openbitfun-border-subtle, rgba(18, 24, 34, .11)); + --lx-border-strong: var(--openbitfun-border, rgba(18, 24, 34, .18)); + --lx-text: var(--openbitfun-text, #20242a); + --lx-text-soft: var(--openbitfun-text-secondary, #505866); + --lx-text-muted: var(--openbitfun-text-muted, #727b89); + --lx-success: var(--openbitfun-success, #218c5b); + --lx-warning: var(--openbitfun-warning, #b97818); + --lx-error: var(--openbitfun-error, #cf4650); + --lx-info: var(--openbitfun-info, #237f9c); +} + +html, body { width: 100%; height: 100%; margin: 0; } +html { background: var(--lx-bg); } +body { + overflow: hidden; + background: var(--lx-bg); + color: var(--lx-text); + font: 13px/1.45 var(--lx-font); + letter-spacing: 0; +} + +button, input, select, textarea { color: inherit; font: inherit; letter-spacing: 0; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .48; } +[hidden] { display: none !important; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: fixed; + top: 6px; + left: 8px; + z-index: 100; + transform: translateY(-150%); + padding: 6px 9px; + border-radius: var(--lx-radius); + background: var(--lx-accent); + color: white; + text-decoration: none; +} +.skip-link:focus { transform: translateY(0); } + +:focus-visible { + outline: 2px solid var(--lx-accent); + outline-offset: 2px; +} + +.loopx-shell { + display: flex; + flex-direction: column; + width: 100%; + height: 100dvh; + min-width: 0; +} + +.intake-header { + position: relative; + display: grid; + grid-template-columns: minmax(150px, 190px) minmax(300px, 760px) auto; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 64px; + padding: 10px 14px; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-surface); +} +.header-actions { position: relative; display: flex; align-items: center; gap: 5px; } + +.product-mark { display: flex; align-items: center; gap: 9px; min-width: 0; } +.product-mark__glyph { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 30px; + height: 30px; + border: 1px solid color-mix(in srgb, var(--lx-info) 55%, var(--lx-border)); + border-radius: 7px; + background: color-mix(in srgb, var(--lx-info) 12%, transparent); + color: var(--lx-info); + font: 700 10px/1 var(--lx-mono); +} +.product-mark__copy { display: flex; flex-direction: column; min-width: 0; } +.product-mark__copy strong { font-size: 14px; } +.product-mark__copy span { + overflow: hidden; + color: var(--lx-text-muted); + font-size: 10.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.intake-form { + display: grid; + grid-template-columns: 18px minmax(100px, 1fr) auto 34px; + align-items: center; + gap: 7px; + min-width: 0; + height: 40px; + padding: 3px 4px 3px 11px; + border: 1px solid var(--lx-border-strong); + border-radius: var(--lx-radius); + background: var(--lx-elevated); +} +.intake-form:focus-within { border-color: var(--lx-accent); } +.field-icon { display: grid; place-items: center; color: var(--lx-text-muted); } +.intake-form input { + min-width: 0; + height: 32px; + padding: 0; + border: 0; + outline: 0; + background: transparent; +} +.intake-form input::placeholder, textarea::placeholder { color: var(--lx-text-muted); } +.model-field { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + height: 28px; + padding: 0 6px 0 8px; + border: 1px solid var(--lx-border); + border-radius: 6px; + background: var(--lx-element); + color: var(--lx-text-soft); + font-size: 11px; + cursor: pointer; +} +.model-field:hover, .model-field:focus-within { + border-color: color-mix(in srgb, var(--lx-accent) 56%, var(--lx-border)); + background: var(--lx-elevated); +} +.model-field__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.model-field__select { + min-width: 0; + width: clamp(96px, 18vw, 220px); + border: 0; + background: transparent; + color: var(--lx-text); + font: inherit; + font-size: 11px; + cursor: pointer; + outline: none; +} +.model-field__select[data-loading="true"] { color: var(--lx-text-muted); } + +.icon-button { + display: inline-grid; + place-items: center; + flex: 0 0 auto; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--lx-border); + border-radius: 7px; + background: transparent; + color: var(--lx-text-soft); +} +.icon-button:hover { border-color: var(--lx-border-strong); background: var(--lx-hover); color: var(--lx-text); } +.icon-button--small { width: 28px; height: 28px; } +.icon-button--accent { border-color: var(--lx-accent); background: var(--lx-accent); color: white; } +.icon-button--accent:hover { border-color: var(--lx-accent-hover); background: var(--lx-accent-hover); color: white; } +.is-spinning svg { animation: spin .9s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +.notice, .unsupported-banner { + flex: 0 0 auto; + margin: 8px 12px 0; + padding: 8px 10px; + border: 1px solid var(--lx-border); + border-radius: var(--lx-radius); + background: var(--lx-element); + color: var(--lx-text-soft); + font-size: 12px; +} +.notice[data-tone="error"] { border-color: color-mix(in srgb, var(--lx-error) 45%, var(--lx-border)); color: var(--lx-error); } +.notice[data-tone="success"] { border-color: color-mix(in srgb, var(--lx-success) 45%, var(--lx-border)); color: var(--lx-success); } +.unsupported-banner { + display: flex; + align-items: flex-start; + gap: 10px; + border-color: color-mix(in srgb, var(--lx-error) 42%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-error) 8%, var(--lx-bg)); +} +.unsupported-banner p { margin: 2px 0 0; color: var(--lx-text-soft); } +.approval-alert { + flex: 0 0 auto; + display: grid; + grid-template-columns: 24px minmax(220px, 1fr) auto; + align-items: center; + gap: 10px; + margin: 8px 12px 0; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--lx-warning) 52%, var(--lx-border)); + border-radius: var(--lx-radius); + background: color-mix(in srgb, var(--lx-warning) 5%, var(--lx-elevated)); +} +.approval-alert__signal { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: color-mix(in srgb, var(--lx-warning) 18%, transparent); + color: var(--lx-warning); + font-weight: 800; +} +.approval-alert__copy { + min-width: 0; + padding: 1px 4px; + border: 0; + outline: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} +.approval-alert__copy:focus-visible { + border-radius: 4px; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--lx-warning) 42%, transparent); +} +.approval-alert__copy strong { display: block; font-size: 12.5px; line-height: 1.4; overflow-wrap: anywhere; } +.approval-alert__copy p { margin: 3px 0 0; color: var(--lx-text-soft); font-size: 11.5px; line-height: 1.45; overflow-wrap: anywhere; } +.approval-alert__actions { display: flex; align-items: center; gap: 6px; } +.approval-alert__actions button { min-height: 32px; white-space: nowrap; } +.status-icon { + display: grid; + place-items: center; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--lx-error); + color: white; + font-weight: 700; +} + +.environment-panel { + position: relative; + flex: 0 0 auto; +} +.environment-panel > summary { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 4px 9px; + border: 1px solid var(--lx-border); + border-radius: 7px; + background: transparent; + color: var(--lx-text-soft); + cursor: pointer; + list-style: none; +} +.environment-panel > summary::-webkit-details-marker { display: none; } +.environment-panel > summary:hover, .environment-panel[open] > summary { border-color: var(--lx-border-strong); background: var(--lx-hover); } +.environment-panel > summary strong { color: var(--lx-text); font-size: 10px; } +.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--lx-text-muted); } +.status-dot[data-status="available"], .status-dot[data-status="ready"], .status-dot[data-status="completed"] { background: var(--lx-success); } +.status-dot[data-status="checking"], .status-dot[data-status="running"], .status-dot[data-status="queued"] { background: var(--lx-info); } +.status-dot[data-status="disabled"] { background: var(--lx-text-muted); } +.status-dot[data-status="degraded"], .status-dot[data-status="waiting_for_user"], .status-dot[data-status="retry_wait"] { background: var(--lx-warning); } +.status-dot[data-status="unavailable"], .status-dot[data-status="blocked"], .status-dot[data-status="failed"] { background: var(--lx-error); } +.environment-body { + position: absolute; + z-index: 30; + top: calc(100% + 8px); + right: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + gap: 14px; + width: min(760px, calc(100vw - 24px)); + max-height: min(520px, calc(100dvh - 86px)); + padding: 12px; + overflow: auto; + border: 1px solid var(--lx-border-strong); + border-radius: var(--lx-radius); + background: var(--lx-elevated); + box-shadow: 0 14px 36px color-mix(in srgb, var(--lx-bg) 62%, transparent); +} +.environment-checked { grid-column: 1 / -1; color: var(--lx-text-muted); font-size: 10px; } +.environment-remediation { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 58px; + padding: 10px 11px; + border: 1px solid color-mix(in srgb, var(--lx-warning) 40%, var(--lx-border)); + border-radius: 7px; + background: color-mix(in srgb, var(--lx-warning) 7%, var(--lx-elevated)); +} +.environment-remediation[data-state="installing"] { border-color: color-mix(in srgb, var(--lx-info) 42%, var(--lx-border)); background: color-mix(in srgb, var(--lx-info) 6%, var(--lx-elevated)); } +.environment-remediation__indicator { + display: grid; + width: 20px; + height: 20px; + place-items: center; + border-radius: 50%; + background: var(--lx-warning); + color: white; + font-size: 12px; + font-weight: 700; +} +.environment-remediation__indicator::before { content: "!"; } +.environment-remediation[data-state="installing"] .environment-remediation__indicator { + border: 2px solid color-mix(in srgb, var(--lx-info) 22%, transparent); + border-top-color: var(--lx-info); + background: transparent; + animation: lx-spin .8s linear infinite; +} +.environment-remediation[data-state="installing"] .environment-remediation__indicator::before { content: ""; } +.environment-remediation__copy { min-width: 0; } +.environment-remediation__copy strong { display: block; font-size: 11.5px; } +.environment-remediation__copy p { margin: 2px 0 0; color: var(--lx-text-soft); font-size: 10px; } +.environment-remediation__progress { display: block; width: min(240px, 100%); height: 2px; margin-top: 7px; overflow: hidden; background: color-mix(in srgb, var(--lx-info) 20%, transparent); } +.environment-remediation__progress::after { content: ""; display: block; width: 42%; height: 100%; background: var(--lx-info); animation: lx-progress 1.1s ease-in-out infinite alternate; } +.environment-remediation__action { display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap; } +.environment-body > #retry-environment { grid-column: 2; justify-self: end; } +.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 6px; } +.section-heading h2, .section-heading h3 { margin: 0; font-size: 11px; } +.section-heading > span { color: var(--lx-text-muted); font-size: 10px; } +.candidate-select-all { display: inline-flex; align-items: center; gap: 4px; color: var(--lx-text-muted); font-size: 10px; cursor: pointer; } +.candidate-select-all input { margin: 0; } +.environment-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 5px; } +.environment-fact { + min-width: 0; + padding: 7px 9px; + border: 1px solid var(--lx-border); + border-left: 2px solid var(--lx-border-strong); + border-radius: 6px; + background: var(--lx-element); +} +.environment-fact[data-status="available"] { border-left-color: var(--lx-success); } +.environment-fact[data-status="disabled"] { border-left-color: var(--lx-text-muted); } +.environment-fact[data-status="degraded"] { border-left-color: var(--lx-warning); } +.environment-fact[data-status="unavailable"] { border-left-color: var(--lx-error); } +.environment-fact__title { display: flex; align-items: center; justify-content: space-between; gap: 5px; } +.environment-fact__title strong { overflow: hidden; font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } +.environment-fact__title span { color: var(--lx-text-muted); font: 9.5px/1.2 var(--lx-mono); } +.environment-fact__status-actions { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 7px; } +.environment-fact__action { min-height: 22px; padding: 2px 7px; font-size: 9.5px; white-space: nowrap; } +.environment-fact p { margin: 3px 0 0; overflow: hidden; color: var(--lx-text-muted); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; } +@keyframes lx-spin { to { transform: rotate(360deg); } } +@keyframes lx-progress { from { transform: translateX(-15%); } to { transform: translateX(145%); } } +@media (prefers-reduced-motion: reduce) { + .environment-remediation[data-state="installing"] .environment-remediation__indicator, + .environment-remediation__progress::after { animation: none; } +} + +.text-button, .primary-button, .danger-button { + min-height: 30px; + padding: 5px 10px; + border: 1px solid var(--lx-border); + border-radius: 7px; + background: transparent; + color: var(--lx-text-soft); +} +.text-button:hover { background: var(--lx-hover); color: var(--lx-text); } +.primary-button { border-color: var(--lx-accent); background: var(--lx-accent); color: white; } +.primary-button:hover { border-color: var(--lx-accent-hover); background: var(--lx-accent-hover); } +.danger-button { border-color: color-mix(in srgb, var(--lx-error) 45%, var(--lx-border)); color: var(--lx-error); } +.danger-button:hover { background: color-mix(in srgb, var(--lx-error) 9%, transparent); } + +.workbench { display: grid; grid-template-columns: var(--rail-width, 286px) 9px minmax(0, 1fr); flex: 1 1 auto; min-height: 0; } +.workbench.tasks-collapsed { grid-template-columns: 52px 9px minmax(0, 1fr); } +.rail-splitter { + position: relative; + cursor: col-resize; + background: transparent; + touch-action: none; +} +.rail-splitter::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + height: 100%; + background: var(--lx-border); + transform: translateX(-50%); + transition: background 0.12s ease, width 0.12s ease; +} +.rail-splitter:hover::after, .rail-splitter.is-focused::after, .rail-splitter.is-dragging::after { + width: 2px; + background: var(--lx-accent, var(--lx-border-strong, #888)); +} +.task-rail { + display: flex; + flex-direction: column; + min-width: 0; + border-right: 1px solid var(--lx-border); + background: var(--lx-surface); +} +.task-rail__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 43px; + padding: 7px 9px 7px 12px; + border-bottom: 1px solid var(--lx-border); +} +.task-rail__header > div { display: flex; align-items: center; gap: 7px; min-width: 0; } +.task-rail__header h2 { margin: 0; font-size: 12px; } +.repository-actions { + display: grid; + gap: 3px; + padding: 8px; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-muted-surface); +} +.repository-actions button { width: 100%; min-height: 32px; font-size: 11px; font-weight: 600; } +.repository-actions span { color: var(--lx-text-muted); font-size: 9.5px; text-align: center; } +.count-badge { + min-width: 18px; + padding: 0 5px; + border-radius: 7px; + background: var(--lx-element); + color: var(--lx-text-muted); + font: 10px/18px var(--lx-mono); + text-align: center; +} +.count-badge--attention { background: color-mix(in srgb, var(--lx-warning) 16%, transparent); color: var(--lx-warning); } +.task-list { + flex: 1 1 0; + min-height: 0; + padding: 6px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} +#task-items { min-height: 0; } +.task-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + width: 100%; + min-height: 46px; + margin: 0 0 3px; + padding: 6px 7px; + border: 1px solid transparent; + border-radius: var(--lx-radius); + background: transparent; + color: var(--lx-text-soft); + text-align: left; +} +.task-item:hover { background: var(--lx-hover); } +.task-item.is-selected { + border-color: color-mix(in srgb, var(--lx-info) 62%, var(--lx-border)); + background: var(--lx-elevated); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--lx-info) 45%, transparent); + color: var(--lx-text); +} +.task-item[data-state="running"] { + position: relative; + overflow: hidden; + border-color: color-mix(in srgb, var(--lx-info) 42%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-info) 10%, var(--lx-element)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--lx-info) 16%, transparent); +} +.task-item[data-state="running"]::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--lx-info); +} +.task-item[data-state="running"]::after { + content: ""; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--lx-info), transparent); + animation: running-sweep 1.7s linear infinite; +} +.task-item[data-state="running"].is-selected { + border-color: color-mix(in srgb, var(--lx-info) 58%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-info) 14%, var(--lx-elevated)); +} +.task-item[data-state="cancelling"], .task-item[data-pending] { + position: relative; + overflow: hidden; + border-color: color-mix(in srgb, var(--lx-warning) 48%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-warning) 12%, var(--lx-element)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--lx-warning) 14%, transparent); +} +.task-item[data-state="cancelling"]::before, .task-item[data-pending]::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--lx-warning); +} +@keyframes running-sweep { + from { transform: translateX(-100%); } + to { transform: translateX(100%); } +} +.task-item__main { display: flex; flex-direction: column; min-width: 0; } +.task-item__main strong { overflow: hidden; font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; } +.task-item__main small { overflow: hidden; color: var(--lx-text-muted); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; } +.task-item__main .task-item__reason { color: var(--lx-text-soft); } +.task-item__state { align-self: start; margin-top: 2px; } +.task-item__hint { + max-width: 62px; + color: var(--lx-text-muted); + font-size: 9px; + font-weight: 600; + line-height: 1.25; + text-align: right; +} +.task-item__hint[data-status="waiting_for_user"] { color: var(--lx-warning); } +.task-item__hint[data-status="recovery_required"] { color: var(--lx-warning); } +.task-item__hint[data-status="failed"] { color: var(--lx-error); } +.task-item__hint[data-status="completed"] { color: var(--lx-success); } +.task-item__hint[data-status="queued"], .task-item__hint[data-status="preparing"] { color: var(--lx-info); } +.task-item__hint--running { + max-width: none; + padding: 2px 6px; + border-radius: 7px; + background: color-mix(in srgb, var(--lx-info) 14%, transparent); + color: var(--lx-info); +} +.task-empty { display: grid; place-items: center; gap: 4px; margin: auto; color: var(--lx-text-muted); text-align: center; } +.task-empty span { font: 22px/1 var(--lx-mono); } +.task-empty p { margin: 0; font-size: 11px; } +.task-rail.is-collapsed { width: 52px; min-width: 52px; } +.task-rail.is-collapsed .task-rail__header { justify-content: center; padding-inline: 5px; } +.task-rail.is-collapsed .task-rail__header > div, .task-rail.is-collapsed .repository-actions, .task-rail.is-collapsed .task-item__main, +.task-rail.is-collapsed .task-empty { display: none; } +.task-rail.is-collapsed .task-item { grid-template-columns: 1fr; min-height: 38px; padding: 6px; } +.task-item__compact { display: none; color: var(--lx-text-soft); font: 600 10px/1 var(--lx-mono); text-align: center; } +.task-rail.is-collapsed .task-item__compact { display: block; } +.task-rail.is-collapsed .task-item__state { + width: 9px; + height: 9px; + margin: auto; + overflow: hidden; + border-radius: 50%; + background: var(--lx-text-muted); + color: transparent; + font-size: 0; +} +.task-rail.is-collapsed .task-item__state[data-status="running"], .task-rail.is-collapsed .task-item__state[data-status="queued"], .task-rail.is-collapsed .task-item__state[data-status="preparing"] { background: var(--lx-info); } +.task-rail.is-collapsed .task-item__state[data-status="waiting_for_user"], .task-rail.is-collapsed .task-item__state[data-status="retry_wait"] { background: var(--lx-warning); } +.task-rail.is-collapsed .task-item__state[data-status="failed"] { background: var(--lx-error); } +.task-rail.is-collapsed .task-item__state[data-status="recovery_required"] { background: var(--lx-warning); } +.task-rail.is-collapsed .task-item__state[data-status="completed"] { background: var(--lx-success); } +.task-rail.is-collapsed #collapse-tasks svg { transform: rotate(180deg); } + +.issue-workspace { container: loopx-main / inline-size; position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--lx-bg); } +.follow-banner { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 7px; + padding: 5px 14px; + border-bottom: 1px solid var(--lx-border); + background: color-mix(in srgb, var(--lx-accent) 7%, transparent); + color: var(--lx-text-soft); + font-size: 10.5px; +} +.follow-banner__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--lx-accent); + animation: follow-pulse 1.6s ease-in-out infinite; +} +@keyframes follow-pulse { 50% { opacity: .35; } } +.issue-empty { + flex: 1 1 auto; + display: grid; + place-content: center; + justify-items: center; + gap: 6px; + padding: 24px; + color: var(--lx-text-muted); + text-align: center; +} +.issue-empty > span { font-size: 26px; opacity: .5; } +.issue-empty p { margin: 0; max-width: 380px; font-size: 11.5px; } +.issue-empty #issue-empty-title { color: var(--lx-text-soft); font-weight: 600; } +.issue-view { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; } +.issue-header { + display: flex; + flex: 0 0 auto; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 9px 14px 8px; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-elevated); +} +.issue-header__titles { min-width: 0; } +.issue-header h1 { display: -webkit-box; overflow: hidden; margin: 0; font-size: 13.5px; line-height: 1.35; text-overflow: ellipsis; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.issue-header__meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 4px; } +.issue-header__updated { color: var(--lx-text-muted); font-size: 10.5px; } +.issue-header__side { display: flex; flex: 0 0 auto; flex-direction: column; align-items: flex-end; gap: 5px; } +.issue-link { + display: block; + max-width: 420px; + margin: 2px 0 0; + overflow: hidden; + color: var(--lx-text-muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; +} +.issue-link:hover { color: var(--lx-accent, inherit); text-decoration: underline; } +.issue-link::after { content: "↗"; margin-left: 4px; text-decoration: none; } +.issue-approval-panel { + position: sticky; + top: 0; + z-index: 3; + flex: 0 0 auto; + max-height: 60dvh; + padding: 16px 18px; + overflow-y: auto; + border-top: 2px solid color-mix(in srgb, var(--lx-warning) 72%, var(--lx-border)); + border-bottom: 1px solid var(--lx-border); + background: var(--lx-surface); + box-shadow: 0 6px 18px color-mix(in srgb, var(--lx-bg) 60%, transparent); +} +.issue-approval-header { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + align-items: start; + gap: 10px; +} +.issue-approval-signal { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: color-mix(in srgb, var(--lx-warning) 18%, transparent); + color: var(--lx-warning); + font-weight: 800; +} +.issue-approval-header h2 { margin: 2px 0 0; font-size: 15px; line-height: 1.35; } +.issue-approval-header .eyebrow { color: var(--lx-warning); } +.issue-approval-message { + margin: 6px 0 0; + padding: 8px 10px; + border-left: 2px solid color-mix(in srgb, var(--lx-warning) 60%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-warning) 6%, transparent); + color: var(--lx-text); + font-size: 12.5px; + line-height: 1.55; + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.issue-approval-raw { margin: 8px 0 0 38px; } +.issue-approval-raw summary { + color: var(--lx-text-muted); + cursor: pointer; + font-size: 10.5px; + user-select: none; +} +.issue-approval-raw summary:hover { color: var(--lx-text-soft); } +.issue-approval-raw p { + margin: 5px 0 0; + padding: 7px 9px; + border: 1px dashed var(--lx-border); + border-radius: 7px; + background: var(--lx-muted-surface); + color: var(--lx-text-muted); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 10.5px; + line-height: 1.55; + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.issue-approval-effects { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 1px; + margin: 10px 0 0 38px; + overflow: hidden; + border: 1px solid var(--lx-border); + border-radius: var(--lx-radius); + background: var(--lx-border); +} +.issue-approval-effects section { min-width: 0; padding: 10px 11px; background: var(--lx-surface); } +.issue-approval-effects strong { color: var(--lx-text); font-size: 12px; } +.issue-approval-effects section[data-tone="approve"] strong { color: var(--lx-success); } +.issue-approval-effects section[data-tone="reject"] strong { color: var(--lx-error); } +.issue-approval-effects p { margin: 4px 0 0; color: var(--lx-text-soft); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; } +.issue-approval-recommendation { + margin: 9px 0 0 38px; + color: var(--lx-text); + font-size: 12px; + line-height: 1.5; +} +.issue-approval-note { margin: 10px 0 0 38px; } +.issue-approval-actions { display: flex; justify-content: flex-end; gap: 7px; margin: 10px 0 0 38px; } +.issue-approval-actions button { min-width: 92px; } +.issue-progress-panel { + flex: 0 0 auto; + padding: 8px 14px 9px; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-surface); +} +.issue-progress-summary { + margin: 0; + color: var(--lx-text-muted); + font-size: 11.5px; + line-height: 1.5; +} +.issue-status-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin-top: 5px; } +.issue-status-row__label { color: var(--lx-text-muted); font-size: 10.5px; } +.issue-status-row strong { color: var(--lx-text); font-size: 12.5px; } +.issue-status-row__detail { min-width: 0; color: var(--lx-text-soft); font-size: 12px; overflow-wrap: anywhere; } +.issue-brief { flex: 0 0 auto; padding: 10px 14px 11px; background: var(--lx-surface); } +.issue-brief__head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } +.issue-brief__head h2 { margin: 0; font-size: 11.5px; } +.issue-brief__meta { color: var(--lx-text-muted); font-size: 10.5px; } +.issue-summary { margin: 4px 0 0; color: var(--lx-text-soft); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; } +.issue-summary > p:first-child { margin-top: 0; } +.issue-facts { display: flex; flex-wrap: wrap; gap: 5px; margin: 8px 0 0; padding: 0; list-style: none; } +.issue-tech { margin: 8px 0 0; } +.issue-tech summary { + display: flex; align-items: center; gap: 4px; cursor: pointer; + color: var(--lx-text-muted); font-size: 10.5px; user-select: none; +} +.issue-tech summary::-webkit-details-marker { display: none; } +.issue-tech summary::after { content: '▸'; margin-left: auto; transition: transform .12s ease; } +.issue-tech[open] summary::after { transform: rotate(90deg); } +.issue-tech[open] summary { margin-bottom: 2px; } +.issue-facts__chip { + display: inline-flex; + max-width: 100%; + align-items: baseline; + gap: 5px; + padding: 2px 8px; + border: 1px solid var(--lx-border); + border-radius: 7px; + background: var(--lx-element); + font-size: 10.5px; +} +.issue-facts__label { color: var(--lx-text-muted); } +.issue-facts__chip strong { overflow: hidden; max-width: 340px; color: var(--lx-text); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; } +.issue-error { + margin: 8px 0 0; + padding: 7px 9px; + border-left: 2px solid var(--lx-error); + background: color-mix(in srgb, var(--lx-error) 7%, transparent); + color: var(--lx-text-soft); + font-size: 11.5px; + line-height: 1.5; + overflow-wrap: anywhere; +} +.issue-description-panel { flex: 0 0 auto; border-top: 1px solid var(--lx-border); background: var(--lx-surface); } +.issue-description-panel summary { + display: flex; + align-items: center; + gap: 8px; + min-height: 30px; + padding: 4px 14px; + color: var(--lx-text-soft); + cursor: pointer; + font-size: 12px; + font-weight: 600; + list-style: none; + user-select: none; +} +.issue-description-panel summary::-webkit-details-marker { display: none; } +.issue-description-panel summary::after { content: '▸'; margin-left: auto; color: var(--lx-text-muted); font-weight: 400; transition: transform .12s ease; } +.issue-description-panel[open] summary::after { transform: rotate(90deg); } +.issue-description-panel summary h2 { margin: 0; font-size: 11.5px; font-weight: 600; } +.issue-number { color: var(--lx-text-muted); font: 10.5px/1.2 var(--lx-mono); } +.issue-description { max-height: 220px; margin: 0; padding: 3px 14px 12px; overflow: auto; color: var(--lx-text-soft); } +.markdown-body > :first-child { margin-top: 0; } +.markdown-body > :last-child { margin-bottom: 0; } +.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body blockquote, .markdown-body pre { margin: 8px 0; } +.markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 { margin: 12px 0 6px; color: var(--lx-text); font-size: 12px; } +.markdown-body ul, .markdown-body ol { padding-left: 22px; } +.markdown-body blockquote { padding-left: 10px; border-left: 2px solid var(--lx-border-strong); color: var(--lx-text-muted); } +.markdown-body code { padding: 1px 4px; border-radius: 4px; background: var(--lx-element); font-family: var(--lx-mono); } +.markdown-body pre { padding: 8px 10px; overflow: auto; background: var(--lx-muted-surface); } +.markdown-body pre code { padding: 0; background: transparent; } +.markdown-body a { color: var(--lx-accent); } +.state-pill, .event-level { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 1px 6px; + border-radius: 7px; + background: var(--lx-element); + color: var(--lx-text-soft); + font-size: 9.5px; + white-space: nowrap; +} +.state-pill[data-state="running"], .state-pill[data-state="completed"] { color: var(--lx-success); } +.state-pill[data-state="waiting_for_user"], .state-pill[data-state="retry_wait"] { color: var(--lx-warning); } +.state-pill[data-state="cancelling"] { color: var(--lx-warning); } +.state-pill[data-state="failed"] { color: var(--lx-error); } +.state-pill[data-state="recovery_required"] { color: var(--lx-warning); } + +.issue-decision-card { + flex: 0 0 auto; + border: 1px solid var(--lx-warning); + border-left: 3px solid var(--lx-warning); + background: color-mix(in srgb, var(--lx-warning) 10%, var(--lx-surface)); + border-radius: var(--lx-radius); + padding: 10px 14px; + display: flex; + flex-direction: column; + gap: 6px; +} +.issue-decision-card[hidden] { display: none; } +.issue-decision-card__message { margin: 0; color: var(--lx-text); font-size: 13px; line-height: 1.5; } +.issue-decision-card__actions { display: flex; gap: 8px; margin-top: 2px; } + +.output-block__thinking { margin-top: 6px; } +.output-block__thinking summary { + cursor: pointer; + color: var(--lx-text-muted); + font-size: 12px; + user-select: none; +} +.output-block__thinking[open] summary { margin-bottom: 6px; } +.task-actions { display: flex; align-items: center; gap: 7px; min-width: 0; } +.task-actions { justify-content: flex-end; } +.task-actions button { min-height: 30px; padding: 4px 10px; font-size: 10.5px; white-space: nowrap; } +.task-actions button.is-pending { color: var(--lx-warning); } + +.log-scroll { position: relative; min-height: 0; flex: 1 1 auto; overflow: auto; scrollbar-gutter: stable; } +.log-list { min-height: 100%; margin: 0; padding: 5px 0 24px; list-style: none; } +.log-row { + display: grid; + grid-template-columns: 64px 72px minmax(0, 1fr); + align-items: start; + gap: 8px; + min-height: 29px; + padding: 5px 14px; + border-bottom: 1px solid color-mix(in srgb, var(--lx-border) 65%, transparent); + font: 10.5px/1.45 var(--lx-mono); +} +.log-row:hover { background: var(--lx-hover); } +.log-row--milestone { border-left: 3px solid var(--lx-border-strong); background: var(--lx-muted-surface); font: 10.5px/1.45 var(--lx-font); } +.log-row--milestone[data-important="true"] { border-left-color: var(--lx-warning); } +.log-row--milestone[data-level="error"] { border-left-color: var(--lx-error); } +.milestone-row__message { min-width: 0; color: var(--lx-text-soft); overflow-wrap: anywhere; white-space: pre-wrap; } +.milestone-row__summary { margin-top: 2px; color: var(--lx-text-muted); font-family: var(--lx-mono); overflow-wrap: anywhere; } +.timeline-stage-card { display: grid; gap: 6px; justify-items: center; max-width: 420px; padding: 12px 14px; border: 1px dashed var(--lx-border-strong); border-radius: var(--lx-radius); background: var(--lx-surface); text-align: center; } +.timeline-stage-card strong { color: var(--lx-text-soft); font-size: 12px; } +.timeline-stage-card p { margin: 0; color: var(--lx-text-muted); font-size: 11px; line-height: 1.55; } +.log-row[data-level="warning"] { background: color-mix(in srgb, var(--lx-warning) 4%, transparent); } +.log-row[data-level="error"] { background: color-mix(in srgb, var(--lx-error) 6%, transparent); } +.log-time { color: var(--lx-text-muted); } +.event-level { justify-content: center; min-height: 17px; padding: 0 4px; font: 9px/1.2 var(--lx-font); } +.event-level[data-level="warning"] { color: var(--lx-warning); } +.event-level[data-level="error"] { color: var(--lx-error); } +.event-level[data-level="debug"], .event-level[data-level="trace"] { color: var(--lx-text-muted); } +.log-source { overflow: hidden; color: var(--lx-info); text-overflow: ellipsis; white-space: nowrap; } +.log-content { min-width: 0; } +.log-message { color: var(--lx-text-soft); overflow-wrap: anywhere; white-space: pre-wrap; } +.turn-output-row { + display: block; + min-height: 0; + margin: 0; + padding: 8px 14px; + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--lx-border) 72%, transparent); + border-left: 3px solid var(--lx-info); + background: transparent; +} +.turn-output-row:hover { background: var(--lx-hover); } +.turn-output-row[data-kind="thinking"] { border-left-color: var(--lx-text-muted); } +.turn-output-row[data-kind="tool"] { border-left-color: var(--lx-accent); } +.turn-output-row[data-kind="model_round_started"], .turn-output-row[data-kind="model_round_completed"] { + border-left-color: var(--lx-success); +} +.turn-output-row[data-level="error"] { border-left-color: var(--lx-error); } +.output-block__header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-width: 0; + margin-bottom: 5px; +} +.output-block__source { + color: var(--lx-info); + font: 600 10px/1.2 var(--lx-font); +} +.output-block__issue { + color: var(--lx-text); + font: 600 10px/1.2 var(--lx-font); + text-decoration: none; +} +.output-block__issue:hover { color: var(--lx-accent); text-decoration: underline; } +.output-block__cursor, +.output-block__meta { + color: var(--lx-text-muted); + font: 9px/1.2 var(--lx-mono); +} +.output-block__meta { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.output-block__message { + color: var(--lx-text); + font: 11px/1.55 var(--lx-mono); + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.turn-output-row[data-kind="thinking"] .output-block__message { color: var(--lx-text-soft); } +.turn-output-row[data-level="error"] .output-block__message { color: var(--lx-error); } +.log-row[data-level="error"] .log-message { color: var(--lx-error); } +.log-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-top: 2px; color: var(--lx-text-muted); font-size: 9px; } +.log-tool-summary { + margin-top: 4px; + padding-left: 8px; + border-left: 2px solid var(--lx-border-strong); + color: var(--lx-text-soft); + font: 10px/1.5 var(--lx-mono); + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.log-details { margin-top: 4px; } +.log-details summary { color: var(--lx-text-muted); cursor: pointer; font-family: var(--lx-font); } +.log-details dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 8px; margin: 4px 0 0; padding: 6px; background: var(--lx-element); } +.log-details dt { color: var(--lx-text-muted); } +.log-details dd { min-width: 0; margin: 0; overflow-wrap: anywhere; } +.log-empty { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 7px; color: var(--lx-text-muted); } +.log-empty p { margin: 0; font-size: 11px; } +.new-events { + position: absolute; + z-index: 5; + bottom: 18px; + left: 50%; + display: grid; + place-items: center; + width: 34px; + height: 34px; + padding: 0; + border: 1px solid var(--lx-accent); + border-radius: 50%; + background: var(--lx-elevated); + color: var(--lx-accent); + box-shadow: 0 7px 20px color-mix(in srgb, var(--lx-bg) 55%, transparent); + transform: translateX(-50%); +} +.new-events:hover { background: var(--lx-accent); color: white; } +@container loopx-main (max-width: 780px) { + .log-header { align-items: stretch; flex-direction: column; } + .log-title-block { width: 100%; min-width: 0; } + .log-title-row { flex-wrap: wrap; } + .log-title-row h1 { flex-basis: calc(100% - 150px); } + .log-title-row .task-actions { margin-left: 0; } + .task-actions { flex-wrap: wrap; } + .task-actions { justify-content: flex-start; } + .issue-approval-context, + .issue-approval-effects { grid-template-columns: 1fr; } + .issue-approval-context section + section { padding-left: 0; border-top: 1px solid var(--lx-border); border-left: 0; } + .issue-approval-effects section + section { border-top: 1px solid var(--lx-border); border-left: 0; } + .issue-progress-grid { grid-template-columns: 1fr; } + .issue-progress-grid > section { border-right: 0; border-bottom: 1px solid var(--lx-border); } + .issue-progress-grid > section:last-child { border-bottom: 0; } +} + +.dialog { + width: min(480px, calc(100vw - 28px)); + max-height: min(720px, calc(100dvh - 28px)); + padding: 0; + overflow: hidden; + border: 1px solid var(--lx-border-strong); + border-radius: var(--lx-radius); + background: var(--lx-elevated); + color: var(--lx-text); + box-shadow: 0 18px 56px color-mix(in srgb, var(--lx-bg) 70%, transparent); +} +.dialog--wide { width: min(720px, calc(100vw - 28px)); } +.dialog::backdrop { background: color-mix(in srgb, var(--lx-bg) 72%, transparent); } +.dialog form { display: flex; flex-direction: column; max-height: inherit; } +.dialog__header, .dialog__footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 13px; } +.dialog__header { border-bottom: 1px solid var(--lx-border); } +.dialog__header h2 { margin: 1px 0 0; font-size: 14px; overflow-wrap: anywhere; } +.eyebrow { color: var(--lx-text-muted); font-size: 9.5px; text-transform: uppercase; } +.dialog__body { min-height: 0; padding: 12px 13px; overflow-y: auto; } +.dialog__body > p { margin: 0; color: var(--lx-text-soft); } +.dialog__body > .dialog-note { margin-top: 9px; color: var(--lx-text-muted); font-size: 10px; } +.dialog-reasons { display: block; margin-top: 7px; color: var(--lx-text-muted); font-size: 10px; overflow-wrap: anywhere; } +.summary-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 9px; } +.summary-badge { + display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 11px; + border: 1px solid color-mix(in srgb, var(--lx-info) 42%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-info) 10%, var(--lx-element)); color: var(--lx-text); +} +.summary-badge--link { color: var(--lx-info); overflow-wrap: anywhere; } +.summary-section { margin: 0 0 9px; } +.summary-section strong { display: block; margin-bottom: 3px; font-size: 12px; } +.summary-section p, .summary-section ul { margin: 0; padding-left: 18px; color: var(--lx-text-soft); font-size: 11.5px; } +.summary-rejected { margin-top: 4px; color: var(--lx-text-muted); font-size: 10.5px; } +.summary-rejected summary { cursor: pointer; } +.summary-rejected div { padding-left: 12px; } +.summary-pending { margin: 0 0 9px; padding: 6px 8px; border-radius: 7px; font-size: 11.5px; + border: 1px solid color-mix(in srgb, var(--lx-warning, #d29922) 45%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-warning, #d29922) 10%, transparent); color: var(--lx-text); } +.summary-receipts { margin-top: 4px; color: var(--lx-text-muted); font-size: 10.5px; } +.summary-receipts summary { cursor: pointer; } +.summary-receipts__body { + margin: 6px 0 0; padding: 8px; max-height: 220px; overflow: auto; white-space: pre-wrap; + border: 1px solid var(--lx-border); border-radius: 7px; background: var(--lx-muted-surface); + color: var(--lx-text-soft); font-size: 10px; +} +.dialog__footer { border-top: 1px solid var(--lx-border); } +.dialog__footer--split > div { display: flex; gap: 6px; } +.preview-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0 0 13px; background: var(--lx-border); } +.preview-facts > div { min-width: 0; padding: 7px 8px; background: var(--lx-muted-surface); } +.preview-facts dt { color: var(--lx-text-muted); font-size: 9.5px; } +.preview-facts dd { margin: 2px 0 0; overflow: hidden; color: var(--lx-text-soft); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } +.candidate-list, .permission-list { display: grid; gap: 4px; margin-bottom: 13px; } +.candidate-item, .permission-item { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + align-items: start; + gap: 7px; + padding: 7px 8px; + border: 1px solid var(--lx-border); + border-radius: 7px; + color: var(--lx-text-soft); +} +.candidate-item input, .permission-item input { margin: 2px 0 0; accent-color: var(--lx-accent); } +.candidate-copy, .permission-copy { display: flex; flex-direction: column; min-width: 0; } +.candidate-copy strong, .permission-copy strong { font-size: 10.5px; overflow-wrap: anywhere; } +.candidate-copy small, .permission-copy small { color: var(--lx-text-muted); font-size: 9.5px; } +.candidate-state { color: var(--lx-text-muted); font-size: 9.5px; } +.candidate-item[data-state="closed"] .candidate-state, .candidate-item[data-state="merged"] .candidate-state { color: var(--lx-success); } +.permission-item[data-risk="high"] { border-color: color-mix(in srgb, var(--lx-warning) 38%, var(--lx-border)); } +.dialog-warning { margin-top: 5px; padding: 7px 8px; border-left: 2px solid var(--lx-warning); background: color-mix(in srgb, var(--lx-warning) 7%, transparent); color: var(--lx-text-soft); font-size: 10.5px; } +.note-field { display: flex; flex-direction: column; gap: 5px; margin-top: 12px; color: var(--lx-text-soft); font-size: 10.5px; } +.note-field textarea { width: 100%; resize: vertical; padding: 7px; border: 1px solid var(--lx-border); border-radius: 7px; outline: 0; background: var(--lx-muted-surface); } +.note-field textarea:focus { border-color: var(--lx-accent); } + +.issue-columns { + display: grid; + flex: 1 1 auto; + grid-template-columns: minmax(380px, var(--issue-detail-width, 620px)) 9px minmax(360px, 1fr); + min-height: 0; +} +.issue-detail { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow-y: auto; + border-right: 1px solid var(--lx-border); + background: var(--lx-bg); + scrollbar-gutter: stable; +} +.issue-detail:has(#issue-approval-panel:not([hidden])) { background: var(--lx-surface); } +.issue-splitter { + position: relative; + cursor: col-resize; + background: var(--lx-bg); + touch-action: none; +} +.issue-splitter::after { + position: absolute; + inset: 0 4px; + border-radius: 999px; + background: transparent; + content: ""; + transition: background .12s ease; +} +.issue-splitter:hover::after, .issue-splitter.is-focused::after, .issue-splitter.is-dragging::after { + background: color-mix(in srgb, var(--lx-accent) 45%, var(--lx-border)); +} +.issue-splitter:focus { outline: 0; } +.issue-timeline { display: flex; flex: 1 1 auto; flex-direction: column; min-width: 0; min-height: 140px; } +.timeline-header { + display: flex; + flex: 0 0 auto; + align-items: baseline; + justify-content: space-between; + gap: 10px; + min-height: 30px; + padding: 5px 14px; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-muted-surface); +} +.timeline-header h2 { margin: 0; font-size: 11.5px; } +.timeline-scope { overflow: hidden; color: var(--lx-text-muted); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } + +@media (max-width: 900px) { + .intake-header { grid-template-columns: minmax(110px, 150px) minmax(260px, 1fr) auto; } + .environment-body { grid-template-columns: 1fr; align-items: stretch; } + .environment-body > #retry-environment { grid-column: 1; } + .environment-grid { grid-template-columns: 1fr; } +} + +/* 宽屏三列(任务 | 详情 | 日志);只在右侧工作区真正变窄时上下堆叠。 */ +@container loopx-main (max-width: 780px) { + .issue-columns { grid-template-columns: 1fr; grid-template-rows: minmax(240px, 1fr) auto; } + .issue-detail { + order: 2; + max-height: 45%; + border-top: 1px solid var(--lx-border); + border-right: 0; + } + .issue-splitter { display: none; } + .issue-timeline { order: 1; } + .issue-approval-panel { position: static; max-height: none; } +} + +@media (max-width: 680px) { + .intake-header { grid-template-columns: minmax(0, 1fr) auto; gap: 7px; } + .product-mark { grid-column: 1; } + .intake-form { grid-column: 1 / -1; grid-row: 2; } + .header-actions { grid-column: 2; grid-row: 1; } + .environment-remediation { grid-template-columns: 22px minmax(0, 1fr); } + .environment-remediation__action { grid-column: 1 / -1; } + .environment-grid { grid-template-columns: 1fr; } + .workbench { grid-template-columns: 1fr; grid-template-rows: minmax(92px, 30dvh) minmax(0, 1fr); } + .workbench.tasks-collapsed { grid-template-columns: 1fr; grid-template-rows: 44px minmax(0, 1fr); } + .rail-splitter { display: none; } + .task-rail { border-right: 0; border-bottom: 1px solid var(--lx-border); } + .task-rail.is-collapsed { width: auto; min-width: 0; height: 44px; } + .task-rail.is-collapsed .task-list, .task-rail.is-collapsed .task-empty { display: none; } + .task-rail.is-collapsed .task-rail__header { justify-content: space-between; } + .task-rail.is-collapsed .task-rail__header > div { display: flex; } + .log-row { grid-template-columns: 54px minmax(0, 1fr); gap: 6px; padding-inline: 8px; } + .log-source { display: none; } + .preview-facts { grid-template-columns: 1fr; } + .approval-alert { grid-template-columns: 22px minmax(0, 1fr); } + .approval-alert__actions { grid-column: 1 / -1; justify-content: flex-end; } + .approval-alert__actions button { flex: 1 1 0; } + .issue-approval-effects, + .issue-approval-recommendation, + .issue-approval-note, + .issue-approval-actions { margin-left: 0; } +} + +@media (max-width: 430px) { + .model-field { max-width: 150px; } + .model-field__select { width: 88px; } + .task-actions { justify-content: flex-start; flex-wrap: wrap; } + .dialog__footer { align-items: stretch; flex-direction: column-reverse; } + .dialog__footer > button { width: 100%; } + .dialog__footer--split > div { display: grid; grid-template-columns: 1fr 1fr; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-runtime.test.mjs b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-runtime.test.mjs new file mode 100644 index 0000000000..6c051d6066 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-runtime.test.mjs @@ -0,0 +1,692 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import test from 'node:test'; + +const requireFromWebUi = createRequire( + new URL('../../../../../../../../../web-ui/package.json', import.meta.url), +); +const { JSDOM, VirtualConsole } = requireFromWebUi('jsdom'); + +const ASSET_ROOT = new URL('../', import.meta.url); + +async function readAsset(name) { + return readFile(new URL(name, ASSET_ROOT), 'utf8'); +} + +async function waitFor(predicate, description) { + const deadline = Date.now() + 2000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + assert.fail(`timed out waiting for ${description}`); +} + +function plain(value) { + return JSON.parse(JSON.stringify(value)); +} + +function installBrowserShims(window) { + window.requestAnimationFrame = (callback) => { + queueMicrotask(() => callback(Date.now())); + return 1; + }; + window.cancelAnimationFrame = () => {}; + window.setInterval = () => 1; + window.clearInterval = () => {}; + window.document.querySelectorAll('dialog').forEach((dialog) => { + dialog.showModal = () => { + dialog.open = true; + dialog.setAttribute('open', ''); + }; + dialog.close = () => { + dialog.open = false; + dialog.removeAttribute('open'); + }; + }); +} + +function issueKey(number = 2382) { + return { + repository: { + host: 'github.com', + owner: 'GCWing', + repository: 'BitFun', + }, + kind: 'issue', + number, + }; +} + +function taskSnapshot(now) { + return { + taskId: 'task-2382-1', + batchId: 'batch-1', + identity: { item: issueKey(), attempt: 1 }, + generation: 2, + revision: 7, + goalId: 'goal-2382', + agentId: 'agent-loopx', + state: 'running', + phase: 'agent_running', + workspacePath: 'D:\\BitFun-worktrees\\issue-2382', + modelId: 'primary', + grantedScopes: [ + 'workspace_read', + 'workspace_write', + 'git_local', + 'github_read', + 'agent_execution', + ], + currentTurnId: 'turn-4', + currentTool: 'cargo test', + lastOutputAt: now - 5000, + lastAgentSummary: null, + lastAgentSummaryAt: null, + deadlineAt: now + 120000, + retryAt: null, + error: null, + settlement: {}, + createdAt: now - 60000, + updatedAt: now - 5000, + }; +} + +function controllerSnapshot(now, task) { + const available = (version, detail) => ({ + status: 'available', + version, + detail, + checkedAt: now - 1000, + }); + return { + schemaVersion: 1, + streamId: 'stream-runtime-1', + cursor: 2, + revision: 11, + executionDomain: 'local_desktop', + executionSupport: 'supported', + unsupportedReason: null, + environment: { + revision: 4, + status: 'degraded', + core: { + sidecar: available('0.5.1', 'Pinned adapter ready'), + gitWorktree: available('2.51.0', 'Worktree service ready'), + agentModel: available('primary', 'Model available'), + }, + optional: { + pythonFallback: { + status: 'unavailable', + detail: 'Optional fallback is not installed', + checkedAt: now - 1000, + }, + githubAuth: available('gh', 'Authenticated'), + }, + checkedAt: now - 1000, + }, + tasks: [task], + generatedAt: now, + }; +} + +function historyEvents(now) { + return [ + { + streamId: 'stream-runtime-1', + cursor: 1, + taskId: 'task-2382-1', + generation: 2, + revision: 6, + kind: 'task_created', + level: 'info', + source: 'controller', + phase: 'queued', + message: 'Task created after intake confirmation', + important: true, + details: { attempt: '1' }, + occurredAt: now - 10000, + }, + { + streamId: 'stream-runtime-1', + cursor: 2, + taskId: 'task-2382-1', + generation: 2, + revision: 7, + kind: 'log', + level: 'info', + source: 'agent', + phase: 'agent_running', + message: 'Tool started: ExecCommand', + important: false, + toolName: 'ExecCommand', + deadlineAt: now + 120000, + details: { + activity: 'started', + toolName: 'ExecCommand', + summary: 'cargo test -p bitfun-core', + }, + occurredAt: now - 5000, + }, + ]; +} + +function intakePreview(now) { + return { + fingerprint: 'sha256:runtime-preview', + target: { targetType: 'item', item: issueKey() }, + repository: issueKey().repository, + workspace: { + disposition: 'existing_worktree', + path: 'D:\\BitFun-worktrees\\issue-2382', + repositoryVerified: true, + }, + candidates: [{ + key: issueKey(), + url: 'https://github.com/GCWing/BitFun/issues/2382', + title: 'Keep LoopX tasks alive outside the MiniApp tab', + state: 'open', + fromRepository: false, + hasImages: false, + defaultSelected: true, + }], + truncated: false, + model: { modelId: 'primary', available: true, supportsImages: true }, + permissionScopes: [ + 'workspace_read', + 'workspace_write', + 'agent_execution', + 'publish', + ], + resolvedAt: now, + expiresAt: now + 60000, + }; +} + +test('thin client boots from host state and completes the confirmed intake flow', async () => { + const [html, ui] = await Promise.all([ + readAsset('index.html'), + readAsset('ui.js'), + ]); + const virtualConsole = new VirtualConsole(); + const jsdomErrors = []; + virtualConsole.on('jsdomError', (error) => jsdomErrors.push(error)); + const dom = new JSDOM(html, { + url: 'https://miniapp.invalid/builtin-bitfun-loopx/', + runScripts: 'outside-only', + pretendToBeVisual: true, + virtualConsole, + }); + const { window } = dom; + installBrowserShims(window); + + const now = Date.now(); + const task = taskSnapshot(now); + const snapshot = controllerSnapshot(now, task); + const events = historyEvents(now); + const preview = intakePreview(now); + const callOrder = []; + const attachRequests = []; + const eventRequests = []; + const resolveRequests = []; + const createRequests = []; + const storedHistory = []; + const forbiddenAccesses = []; + let eventListener = null; + + const loopx = { + onEvent(listener) { + callOrder.push('onEvent'); + eventListener = listener; + }, + offEvent(listener) { + assert.equal(listener, eventListener); + }, + async attach(request) { + callOrder.push('attach'); + attachRequests.push(request); + return { snapshot: structuredClone(snapshot) }; + }, + async eventsSince(request) { + eventRequests.push(request); + return { + status: 'current', + streamId: snapshot.streamId, + events: structuredClone(events), + nextCursor: snapshot.cursor, + hasMore: false, + }; + }, + async turnOutputSince(request) { + return { + status: 'current', + taskId: task.taskId, + turnId: task.currentTurnId, + streamId: 'output-stream-1', + events: request.afterCursor > 0 ? [] : [{ + cursor: 1, + turnId: task.currentTurnId, + roundId: 'round-1', + kind: 'thinking', + text: 'Inspecting the issue and repository state', + toolName: null, + toolState: null, + isEnd: true, + }, { + cursor: 2, + turnId: task.currentTurnId, + roundId: 'round-1', + kind: 'tool', + text: 'cargo test -p bitfun-core', + toolName: 'ExecCommand', + toolState: 'started', + isEnd: false, + }], + nextCursor: 2, + hasMore: false, + message: null, + }; + }, + async resolveIntake(request) { + resolveRequests.push(request); + return { preview: structuredClone(preview) }; + }, + async createTask(request) { + createRequests.push(request); + return { + outcomes: [{ + item: issueKey(), + kind: 'opened_existing', + taskId: task.taskId, + attempt: 1, + }], + snapshotRevision: snapshot.revision, + }; + }, + async action() { + assert.fail('the intake smoke test must not dispatch task actions'); + }, + }; + const appTarget = { + locale: 'en-US', + loopx, + storage: { + async get() { return []; }, + async set(key, value) { storedHistory.push([key, structuredClone(value)]); }, + }, + onLocaleChange() {}, + onActivate() {}, + }; + window.app = new Proxy(appTarget, { + get(target, property, receiver) { + if (['agent', 'call', 'worker'].includes(String(property))) { + forbiddenAccesses.push(String(property)); + } + return Reflect.get(target, property, receiver); + }, + }); + + try { + window.eval(ui); + // The merged timeline renders one compact block per output kind: the + // turn's thinking summary block plus the running tool block. + await waitFor(() => window.document.querySelectorAll('#log-list .log-row').length === 2, 'initial rendering'); + + assert.deepEqual(callOrder.slice(0, 2), ['onEvent', 'attach']); + assert.deepEqual(plain(attachRequests[0]), {}); + assert.deepEqual(plain(eventRequests), [{ + streamId: snapshot.streamId, + afterCursor: 0, + limit: 250, + }]); + assert.equal(window.document.querySelector('#loopx-app').getAttribute('aria-busy'), 'false'); + assert.equal(window.document.querySelector('#task-count').textContent, '1'); + assert.match(window.document.querySelector('#task-items').textContent, /GCWing\/BitFun · Issue #2382/); + assert.equal(window.document.querySelector('#environment-status').textContent, 'Degraded'); + assert.match(window.document.querySelector('#core-environment-list').textContent, /0\.5\.1/); + assert.match(window.document.querySelector('#log-list').textContent, /Issue #2382/); + assert.match(window.document.querySelector('#log-list').textContent, /Inspecting the issue/); + assert.match(window.document.querySelector('#log-list').textContent, /cargo test -p bitfun-core/); + + assert.equal(typeof eventListener, 'function'); + eventListener({ + event: { + streamId: snapshot.streamId, + cursor: 3, + taskId: task.taskId, + generation: 2, + revision: 7, + kind: 'log', + level: 'warning', + source: 'git', + phase: 'agent_running', + message: 'Validation produced one warning', + important: false, + details: {}, + occurredAt: now, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.doesNotMatch(window.document.querySelector('#log-list').textContent, /Validation produced one warning/); + + const connectionChanges = []; + const connectionObserver = new window.MutationObserver(() => { + connectionChanges.push(window.document.querySelector('#connection-label').textContent); + }); + connectionObserver.observe(window.document.querySelector('#connection-label'), { + childList: true, + characterData: true, + subtree: true, + }); + eventListener({ + event: { + streamId: snapshot.streamId, + cursor: 4, + taskId: task.taskId, + generation: 2, + revision: 8, + kind: 'state_changed', + level: 'info', + source: 'controller', + phase: 'queued', + message: 'Task returned to the queue', + important: false, + details: {}, + occurredAt: now, + }, + }); + await waitFor(() => attachRequests.length === 2, 'background snapshot refresh'); + await new Promise((resolve) => setTimeout(resolve, 0)); + connectionObserver.disconnect(); + assert.equal(window.document.querySelector('#connection-label').textContent, 'Connected'); + assert.ok(!connectionChanges.includes('Resynchronizing')); + + const originalDateNow = window.Date.now; + const resumedAt = originalDateNow() + 60000; + window.Date.now = () => resumedAt; + window.dispatchEvent(new window.Event('focus')); + await waitFor(() => attachRequests.length === 3, 'host resume reattach'); + assert.equal(attachRequests[2].resumeDetected, true); + window.Date.now = originalDateNow; + + const input = window.document.querySelector('#intake-input'); + input.value = 'https://github.com/GCWing/BitFun/issues/2382'; + window.document.querySelector('#intake-form').dispatchEvent(new window.Event('submit', { + bubbles: true, + cancelable: true, + })); + await waitFor(() => window.document.querySelector('#intake-dialog').open, 'intake dialog'); + + assert.deepEqual(plain(resolveRequests), [{ + input: 'https://github.com/GCWing/BitFun/issues/2382', + modelId: 'auto', + }]); + assert.equal(window.document.querySelector('#preview-repository').textContent, 'GCWing/BitFun'); + assert.equal( + window.document.querySelector('#preview-workspace').textContent, + 'D:\\BitFun-worktrees\\issue-2382', + ); + assert.match(window.document.querySelector('#candidate-list').textContent, /Keep LoopX tasks alive/); + assert.equal(window.document.querySelector('input[name="candidate"]').checked, true); + // High-risk scopes render unchecked by default but the intake gate now + // requires an explicit grant of every preview scope before creation: + // simulate the owner checking `publish` before confirming. + assert.equal( + window.document.querySelector('input[name="permission"][value="publish"]').checked, + false, + ); + const publishGrant = window.document.querySelector('input[name="permission"][value="publish"]'); + publishGrant.checked = true; + publishGrant.dispatchEvent(new window.Event('change', { bubbles: true })); + assert.deepEqual(plain(storedHistory), [[ + 'loopx.intakeHistory', + ['https://github.com/GCWing/BitFun/issues/2382'], + ]]); + assert.equal( + window.document.querySelector('#intake-history option').value, + 'https://github.com/GCWing/BitFun/issues/2382', + ); + + window.document.querySelector('#intake-confirm-form').dispatchEvent(new window.Event('submit', { + bubbles: true, + cancelable: true, + })); + await waitFor(() => createRequests.length === 1 && !window.document.querySelector('#intake-dialog').open, 'task creation'); + + assert.equal(createRequests[0].previewFingerprint, preview.fingerprint); + assert.deepEqual(plain(createRequests[0].selectedItems), [issueKey()]); + assert.deepEqual(plain(createRequests[0].grantedScopes), [ + 'workspace_read', + 'workspace_write', + 'agent_execution', + 'publish', + ]); + assert.equal(createRequests[0].modelId, 'primary'); + assert.equal(createRequests[0].retryTerminal, false); + assert.ok(createRequests[0].clientRequestId); + assert.match(window.document.querySelector('#notice').textContent, /existing task.*duplicate/i); + assert.equal( + window.document.querySelector('[data-task-id="task-2382-1"]').getAttribute('aria-pressed'), + 'true', + ); + // The creation flow focuses the task logs in the issue workspace; a + // running task renders no decision card (only waiting/recovery states do). + assert.equal(window.document.querySelector('#issue-view').hidden, false); + assert.equal(window.document.querySelector('#issue-decision-card').hidden, true); + assert.deepEqual(forbiddenAccesses, []); + assert.deepEqual(jsdomErrors, []); + } finally { + window.close(); + } +}); + +test('task rail is flat and exposes one repository recovery action', async () => { + const [html, ui] = await Promise.all([ + readAsset('index.html'), + readAsset('ui.js'), + ]); + const virtualConsole = new VirtualConsole(); + const jsdomErrors = []; + virtualConsole.on('jsdomError', (error) => jsdomErrors.push(error)); + const dom = new JSDOM(html, { + url: 'https://miniapp.invalid/builtin-bitfun-loopx/', + runScripts: 'outside-only', + pretendToBeVisual: true, + virtualConsole, + }); + const { window } = dom; + installBrowserShims(window); + + const now = Date.now(); + const makeTask = (taskId, number, state, phase, overrides = {}) => ({ + ...taskSnapshot(now), + taskId, + identity: { + item: issueKey(number), + attempt: 1, + title: `Issue ${number}`, + description: '', + }, + state, + phase, + currentTurnId: null, + currentTool: null, + error: null, + ...overrides, + }); + const waiting = makeTask('task-waiting', 42, 'waiting_for_user', 'waiting_for_approval', { + identity: { + item: issueKey(42), + attempt: 1, + title: '8.29 upgrade from 2.0.2 to 2.0.4: plugin tree failed to load, waiting for service: apiProxy', + description: '', + }, + pendingGateId: 'todo_release_approval', + pendingGateMessage: 'Approve repository write scope for the issue repair', + pendingGateActionKind: 'gate', + lastAgentSummary: 'Implementation has not started because repository write requires parent approval. The repair will add an apiProxy compatibility shim and resilient plugin startup.', + lastAgentSummaryAt: now - 2000, + }); + const failed = makeTask('task-failed', 43, 'recovery_required', 'recovering', { + error: 'LoopX process exited with status 1', + }); + const resolvedUpstream = makeTask('task-resolved-upstream', 46, 'recovery_required', 'recovering', { + error: 'Settlement metadata was incomplete', + lastAgentSummary: 'The original failure path is covered-upstream no-follow-up; no PR is required.', + lastAgentSummaryAt: now - 1500, + }); + const running = makeTask('task-running', 44, 'running', 'agent_running'); + const queued = makeTask('task-queued', 45, 'queued', 'queued'); + const snapshot = controllerSnapshot(now, running); + snapshot.tasks = [queued, running, failed, waiting, resolvedUpstream]; + snapshot.cursor = 1; + snapshot.revision = 24; + const events = [{ + streamId: snapshot.streamId, + cursor: 1, + taskId: waiting.taskId, + generation: waiting.generation, + revision: waiting.revision, + kind: 'approval_required', + level: 'warning', + source: 'controller', + phase: 'waiting_for_approval', + message: 'Approve repository write scope for the issue repair', + important: true, + details: { gateId: 'todo_release_approval', actionKind: 'gate' }, + occurredAt: now - 1000, + }]; + let eventListener = null; + window.app = { + locale: 'en-US', + loopx: { + onEvent(listener) { + eventListener = listener; + }, + offEvent(listener) { + assert.equal(listener, eventListener); + }, + async attach() { + return { snapshot: structuredClone(snapshot) }; + }, + async eventsSince() { + return { + status: 'current', + streamId: snapshot.streamId, + events: structuredClone(events), + nextCursor: snapshot.cursor, + hasMore: false, + }; + }, + async turnOutputSince() { + return { + status: 'current', + taskId: running.taskId, + turnId: running.currentTurnId, + streamId: 'output-stream-1', + events: [], + nextCursor: 0, + hasMore: false, + message: null, + }; + }, + async resolveIntake() { + return { preview: { candidates: [] } }; + }, + async action() { + assert.fail('this test must not submit a decision'); + }, + }, + onLocaleChange() {}, + onActivate() {}, + }; + + try { + window.eval(ui); + await waitFor( + () => window.document.querySelectorAll('#task-items .task-item').length === 5, + 'flat task list', + ); + await waitFor( + () => !window.document.querySelector('#approval-alert').hidden + && window.document.querySelector('[data-task-id="task-waiting"]').getAttribute('aria-pressed') === 'true', + 'automatic approval focus', + ); + + assert.equal(window.document.querySelectorAll('#task-items .task-group').length, 0); + // The rail reads in actual execution order: running first, then the + // queue, then parked/awaiting-owner tasks, then finished work. + assert.deepEqual( + [...window.document.querySelectorAll('#task-items .task-item')].map((item) => item.dataset.taskId), + ['task-running', 'task-queued', 'task-waiting', 'task-failed', 'task-resolved-upstream'], + ); + assert.equal(window.document.querySelector('#repository-actions').hidden, false); + assert.match(window.document.querySelector('#resume-repository').textContent, /repository tasks \(1\)/i); + assert.match( + window.document.querySelector('[data-task-id="task-waiting"]').textContent, + /Pending approval/, + ); + assert.equal(window.document.querySelector('#approval-alert').hidden, false); + assert.match( + window.document.querySelector('[data-task-id="task-failed"]').textContent, + /Pending recovery/, + ); + window.document.querySelector('#reset-loopx').click(); + assert.equal(window.document.querySelector('#reset-loopx-dialog').open, true); + assert.match(window.document.querySelector('#reset-loopx-message').textContent, /5 tasks, 1 log event/); + window.document.querySelector('#reset-loopx-cancel').click(); + assert.equal(window.document.querySelector('#reset-loopx-dialog').open, false); + + window.document.querySelector('[data-task-id="task-waiting"]').click(); + // The waiting task renders the decision card mirroring the pending gate; + // the stale five-stage pipeline projection is gone. + assert.equal(window.document.querySelector('#issue-decision-card').hidden, false); + assert.match( + window.document.querySelector('#issue-decision-card').textContent, + /Needs your decision/i, + ); + assert.match( + window.document.querySelector('#issue-decision-card').textContent, + /Approve repository write scope for the issue repair/i, + ); + assert.equal(window.document.querySelector('#issue-description-panel').hidden, false); + await waitFor( + () => /temporarily unavailable/.test(window.document.querySelector('#issue-description').textContent), + 'selected task metadata refresh', + ); + assert.match(window.document.querySelector('#issue-summary').textContent, /Implementation has not started because repository write requires parent approval/i); + assert.equal(window.document.querySelector('#task-actions button'), null); + assert.equal(window.document.querySelector('#issue-approval-panel').hidden, false); + assert.match(window.document.querySelector('#issue-title').textContent, /plugin tree failed to load/i); + assert.match( + window.document.querySelector('#issue-approval-title').textContent, + /continue handling this issue/i, + ); + assert.match( + window.document.querySelector('#issue-approval-message').textContent, + /requested a decision/i, + ); + assert.match( + window.document.querySelector('#issue-approval-raw-text').textContent, + /Approve repository write scope for the issue repair/, + ); + assert.match(window.document.querySelector('#issue-approval-approve-effect').textContent, /perform the current operation and continue processing/i); + assert.match(window.document.querySelector('#issue-approval-reject-effect').textContent, /do not perform this operation or continue to later steps/i); + assert.doesNotMatch(window.document.querySelector('#issue-view').textContent, /Root cause|todo_|settlement_result|durable_writeback|bounded stage/i); + + const resolvedButton = window.document.querySelector('[data-task-id="task-resolved-upstream"]'); + assert.match(resolvedButton.textContent, /Resolved upstream/); + assert.equal(resolvedButton.dataset.state, 'completed'); + resolvedButton.click(); + assert.equal(window.document.querySelector('#issue-state-pill').textContent, 'Resolved upstream'); + // Resolved-upstream tasks render no decision card; the summary carries + // the closure conclusion. + assert.equal(window.document.querySelector('#issue-decision-card').hidden, true); + assert.match(window.document.querySelector('#issue-summary').textContent, /covered-upstream no-follow-up/i); + assert.equal(window.document.querySelector('#task-actions button'), null); + assert.deepEqual(jsdomErrors, []); + } finally { + window.close(); + } +}); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-source-contract.test.mjs b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-source-contract.test.mjs new file mode 100644 index 0000000000..3c8f37106a --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/test/thin-client-source-contract.test.mjs @@ -0,0 +1,281 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const ASSET_ROOT = new URL('../', import.meta.url); + +async function readAsset(name) { + return readFile(new URL(name, ASSET_ROOT), 'utf8'); +} + +function openingTags(source, tagName) { + return [...source.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi'))] + .map((match) => match[0]); +} + +function tagWithMarker(source, tagName, marker, description) { + const tag = openingTags(source, tagName).find((candidate) => marker.test(candidate)); + assert.ok(tag, `missing ${description}`); + return tag; +} + +function hasAccessibleName(tag) { + return /\baria-label(?:ledby)?\s*=\s*(['"])[^'"]+\1/i.test(tag); +} + +function executableWorkerSource(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, '') + .trim(); +} + +test('LoopX UI is a thin client of the host-owned controller', async () => { + const ui = await readAsset('ui.js'); + const requiredMethods = [ + 'attach', + 'resolveIntake', + 'createTask', + 'action', + 'eventsSince', + 'turnOutputSince', + ]; + + for (const method of requiredMethods) { + assert.ok( + new RegExp(`\\bapp\\.loopx\\.${method}\\s*\\(`).test(ui), + `ui.js must use app.loopx.${method}`, + ); + } + assert.ok( + /\bapp\.loopx\.onEvent\s*\(/.test(ui), + 'ui.js must subscribe to the host loopx:event stream', + ); + + const allowedLoopxMethods = new Set([ + ...requiredMethods, + 'listModels', + 'onEvent', + 'offEvent', + ]); + const usedLoopxMethods = [ + ...ui.matchAll(/\bapp\.loopx\.([A-Za-z][A-Za-z0-9_]*)/g), + ].map((match) => match[1]); + assert.ok(usedLoopxMethods.length > 0, 'ui.js must use the LoopX namespace'); + for (const method of usedLoopxMethods) { + assert.ok( + allowedLoopxMethods.has(method), + `ui.js uses unsupported app.loopx method: ${method}`, + ); + } + assert.match(ui, /remediationAction\s*===\s*['"]install_loopx['"]/); + assert.match(ui, /performAction\(['"]install_loopx['"]/); + assert.match(ui, /window\.setTimeout\(\(\)\s*=>\s*\{[\s\S]*submitLoopxInstallation\(\)[\s\S]*\},\s*50\)/); + for (const phase of [ + 'pointer_down', + 'click_handler_entered', + 'ui_pending_rendered', + 'request_task_started', + 'bridge_call_started', + ]) { + assert.match(ui, new RegExp(`emitInstallDiagnostic\\(['"]${phase}['"]`)); + } + assert.doesNotMatch(ui, /\bupdateControls\s*\(/); + + const forbiddenSurfaces = [ + [/\bapp\.agent\b/, 'MiniApp-owned Agent lifecycle'], + [/\bapp\.call\s*\(/, 'legacy generic worker calls'], + [/\b(?:app\.)?worker\.call\s*\(/, 'direct worker calls'], + [/\bapp\.(?:fs|shell)\b/, 'direct filesystem or shell APIs'], + [/\b(?:argvPrefix|projectDirs?|srcDir)\b/, 'iframe-controlled CLI or checkout paths'], + [/--registry\b|registry\.json|\bregistry(?:Path|Args)\b/i, 'direct registry CLI access'], + // The word alone appears in user-facing cadence copy and comments; only + // actual heartbeat scheduling constructs (calls or starter functions) + // are forbidden — the host owns every wake-up. + [/\bheartbeat(?!-prompt)[a-z_]*\s*\(|\b(?:start|stop|schedule|restart)Heartbeat\b/i, 'iframe-owned heartbeat scheduling'], + ]; + for (const [pattern, description] of forbiddenSurfaces) { + assert.ok(!pattern.test(ui), `ui.js must not contain ${description}`); + } +}); + +test('LoopX worker remains an execution-free compatibility stub', async () => { + const worker = await readAsset('worker.js'); + + assert.ok( + /compatibility|intentionally empty/i.test(worker), + 'worker.js must document its compatibility-only purpose', + ); + assert.ok( + executableWorkerSource(worker) === 'module.exports = {};', + 'worker.js may only export an empty compatibility module', + ); +}); + +test('LoopX metadata disables the Node worker runtime', async () => { + const meta = JSON.parse(await readAsset('meta.json')); + + assert.equal(meta.permissions?.node?.enabled, false); +}); + +test('LoopX HTML exposes an accessible intake, task rail, and log-first workspace', async () => { + const html = await readAsset('index.html'); + const intakeHeader = tagWithMarker( + html, + 'header', + /\b(?:id|class)\s*=\s*(['"])[^'"]*intake[^'"]*\1/i, + 'intake header landmark', + ); + const intakeForm = tagWithMarker( + html, + 'form', + /\b(?:id|class)\s*=\s*(['"])[^'"]*intake[^'"]*\1/i, + 'intake form', + ); + const taskRail = openingTags(html, 'aside').find((tag) => ( + /\b(?:id|class)\s*=\s*(['"])[^'"]*(?:task|rail)[^'"]*\1/i.test(tag) + )); + const logLandmark = [ + ...openingTags(html, 'main'), + ...openingTags(html, 'section'), + ].find((tag) => ( + /\b(?:id|class)\s*=\s*(['"])[^'"]*log[^'"]*\1/i.test(tag) + )); + const logList = tagWithMarker( + html, + '[A-Za-z][A-Za-z0-9:-]*', + /\bid\s*=\s*(['"])log-list\1/i, + '#log-list', + ); + + assert.ok(intakeHeader, 'the intake controls must be grouped in a header'); + assert.ok(hasAccessibleName(intakeForm), 'the intake form needs an accessible name'); + assert.ok(taskRail, 'missing task rail aside landmark'); + assert.ok(hasAccessibleName(taskRail), 'the task rail needs an accessible name'); + assert.ok(logLandmark, 'missing main log landmark'); + assert.match(logLandmark, /\baria-labelledby\s*=\s*(['"])[^'"]+\1/i); + assert.match(logLandmark, /\bid\s*=\s*(['"])log-workspace\1/i); + assert.match(logLandmark, /\btabindex\s*=\s*(['"])-1\1/i); + assert.match(logList, /\brole\s*=\s*(['"])log\1/i); + assert.match(logList, /\baria-live\s*=\s*(['"])off\1/i); + + const labelledBy = logLandmark.match(/\baria-labelledby\s*=\s*(['"])([^'"]+)\1/i)?.[2]; + assert.ok(labelledBy, 'the log landmark must reference its visible title'); + assert.match( + html, + new RegExp(`\\bid\\s*=\\s*(['"])${labelledBy.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\1`, 'i'), + 'the log landmark must reference an existing title element', + ); + + const intakeOffset = html.indexOf(intakeHeader); + const railOffset = html.indexOf(taskRail); + const logOffset = html.indexOf(logLandmark); + assert.ok( + intakeOffset < railOffset && railOffset < logOffset, + 'the document order must be top intake, task rail, then primary log view', + ); + + assert.match(html, /\blist\s*=\s*(['"])intake-history\1/i); + assert.match(html, /\bid\s*=\s*(['"])resume-repository\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-decision-card\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-description-panel\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-number\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-view\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-title\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-state-pill\1/i); + assert.match(html, /\bid\s*=\s*(['"])follow-banner\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-approval-message\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-summary\1/i); + assert.match(html, /\bid\s*=\s*(['"])timeline-title\1/i); + assert.match(html, /\bid\s*=\s*(['"])log-empty-text\1/i); + assert.match(html, /\bid\s*=\s*(['"])approval-alert\1/i); + assert.match(html, /\bid\s*=\s*(['"])environment-remediation\1/i); + assert.match(html, /\bid\s*=\s*(['"])install-loopx\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-approval-panel\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-approval-approve\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-approval-reject\1/i); + assert.match(html, /\bid\s*=\s*(['"])issue-description-panel\1/i); + assert.ok( + html.indexOf('id="issue-approval-panel"') < html.indexOf('id="issue-decision-card"') + && html.indexOf('id="issue-decision-card"') < html.indexOf('id="issue-description-panel"') + && html.indexOf('id="issue-description-panel"') < html.indexOf('id="issue-timeline"'), + 'the approval request, decision card, issue description, and timeline must stack in priority order', + ); + for (const removedId of [ + 'sync-button', + 'mode-key', + 'mode-full', + 'mode-output', + 'log-search', + 'errors-only', + 'export-logs', + 'liveness-panel', + 'approval-alert-review', + 'issue-stage-walker', + 'gate-dialog', + 'issue-detail-dialog', + 'show-all-events', + 'log-title', + 'issue-approval-background', + 'issue-approval-impact', + 'issue-outcome', + 'issue-evidence-list', + 'issue-next-action', + 'issue-progress-panel', + 'issue-progress-summary', + ]) { + assert.doesNotMatch(html, new RegExp(`\\bid\\s*=\\s*(['"])${removedId}\\1`, 'i')); + } +}); + +test('LoopX keeps intake history and renders one flat repository task list', async () => { + const ui = await readAsset('ui.js'); + + assert.match(ui, /app\.storage\.get\s*\(INTAKE_HISTORY_STORAGE_KEY\)/); + assert.match(ui, /app\.storage\.set\s*\(INTAKE_HISTORY_STORAGE_KEY/); + assert.match(ui, /sortedTaskList\(tasks\)\.forEach\(\(task\)\s*=>\s*fragment\.append\(taskButton\(task\)\)\)/); + assert.doesNotMatch(ui, /group\.className\s*=\s*['"]task-group['"]/); + assert.match(ui, /makeActionButton\(text\('resume'\),\s*'resume',\s*task\)/); + assert.match(ui, /task\.lastAgentSummary/); + assert.match(ui, /function isResolvedUpstream\(task\)/); + assert.match(ui, /function issueContext\(task\)/); + assert.match(ui, /function renderTimeline\(\)/); + assert.match(ui, /function displayedTask\(\)/); + assert.doesNotMatch(ui, /issueApiProxy|issueInputModality|issueMacFocus/); + assert.match(ui, /resumeDetected:\s*true/); + assert.match(ui, /outputBlockDomKey/); + assert.match(ui, /syncApprovalAttention/); + assert.match(ui, /task\.pendingGateId/); + assert.match(ui, /visibilitychange/); + assert.match(ui, /pageshow/); + assert.match(ui, /STALE_ACTIVE_REATTACH_MS/); + assert.match(ui, /focusedTaskId/); + assert.match(ui, /focusTaskLogs\(focusedTaskId\s*\|\|\s*null\)/); + assert.match(ui, /selectTask\(taskId\s*\|\|\s*null\)/); + assert.match(ui, /resetLoopxDialog\.close\(\)[\s\S]*resettingLoopxBackground/); +}); + +test('LoopX recovery copy keeps zh/en parity for plan-exhausted cards', async () => { + const ui = await readAsset('ui.js'); + const keys = [ + 'decisionCardTitlePlanExhausted', + 'decisionCardPlanExhaustedHint', + 'recoveryReasonPlanExhausted', + ]; + for (const key of keys) { + const occurrences = [...ui.matchAll(new RegExp(`${key}\s*:`, 'g'))].length; + assert.equal( + occurrences, + 2, + `copy key ${key} must exist once per locale (zh-CN and en-US), found ${occurrences}`, + ); + } + // The recovery card must branch on the plan-exhausted reason so the card is + // not stuck with the generic settlement-unverified copy. + assert.match( + ui, + /planExhausted\s*=\s*recovery\s*&&\s*task\.recoveryReason\s*===\s*['"]plan_exhausted['"]/, + ); + assert.match(ui, /['"]decisionCardTitlePlanExhausted['"]/); + assert.match(ui, /['"]decisionCardPlanExhaustedHint['"]/); +}); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/ui.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/ui.js new file mode 100644 index 0000000000..c0ab6e508b --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/ui.js @@ -0,0 +1,4418 @@ +'use strict'; + +// LoopX is owned by the BitFun host. This file only projects durable snapshots +// and cursor-addressed events into the MiniApp UI. +const app = window.app; +const byId = (id) => document.getElementById(id); +const MAX_EVENTS = 2000; +const MAX_RENDERED_OUTPUT_BLOCKS = 500; +const MAX_TURN_OUTPUT_EVENTS = 4000; +const MAX_OUTPUT_HISTORY_EVENTS = 50000; +const MAX_OUTPUT_EVENT_CHARS = 16000; +const MAX_OUTPUT_BLOCK_CHARS = 120000; +const MAX_OUTPUT_HISTORY_CHARS = 8000000; +const MAX_INTAKE_HISTORY = 12; +const HOST_CLOCK_TICK_MS = 5000; +const HOST_RESUME_GAP_MS = 30000; +const STALE_ACTIVE_REATTACH_MS = 30000; +const MODEL_SELECTION_STORAGE_KEY = 'loopx.modelId'; +const INTAKE_HISTORY_STORAGE_KEY = 'loopx.intakeHistory'; +const HIGH_RISK_SCOPES = new Set([ + 'publish', + 'public_comment', + 'pull_request', + 'merge', + 'production_action', +]); +const SNAPSHOT_EVENT_KINDS = new Set([ + 'task_created', + 'state_changed', + 'phase_changed', + 'approval_required', + 'settlement_recorded', + 'environment_changed', + 'operation_cancelled', + 'snapshot_invalidated', +]); + +const COPY = { + 'zh-CN': { + skipToLogs: '跳到日志', + connecting: '正在连接宿主', + connected: '已连接', + connectionFailed: '连接失败', + intakeLabel: 'GitHub Issue、Pull Request 或仓库链接', + intakePlaceholder: '粘贴 GitHub Issue、PR、仓库或 Issues 列表链接', + model: '模型', + modelAuto: '自动模型', + modelLoading: '正在加载模型…', + modelEmpty: '未找到已启用的文本模型', + modelLoadFailed: '模型列表加载失败', + modelReloadTitle: '刷新模型列表', + modelSelectionChanged: '已切换模型,请重新分析链接。', + modelPrimaryTag: '主模型', + resolve: '分析链接', + resolving: '正在实时复核链接', + resetLoopx: '重置 LoopX', + resettingLoopxBackground: '正在后台清理任务与工作进展;窗口可以继续使用,完成后会自动刷新。', + destructiveAction: '危险操作', + resetLoopxTitle: '清空并重新开始', + resetLoopxMessage: '将停止并删除 {tasks} 个任务、{events} 条日志、全部工作进展和受管工作区。此操作不可撤销。', + resetLoopxRetained: '模型配置、GitHub 登录、MiniApp 设置和干净的 Git 对象缓存会保留;仍在进行的工作区不会被复用。', + resetLoopxConfirm: '清空并重新开始', + resetLoopxApplied: 'LoopX 已清空,可以重新开始测试。', + unsupportedTitle: '当前执行位置不支持 LoopX', + unsupportedDefault: 'LoopX 目前只支持本地 Desktop 工作区;远程工作区不会静默改在本机执行。', + environment: '环境', + coreEnvironment: '核心环境', + optionalEnvironment: '增强能力', + required: '必需', + optional: '可选', + retryEnvironment: '重新检查环境', + installLoopx: '安装兼容版本', + loopxInstallStarted: '正在从官方 GitHub 源仓库下载并校验 LoopX v0.5.1…', + loopxInstallQueued: '安装已在后台开始,可以继续使用当前窗口。', + loopxInstallComplete: 'LoopX v0.5.1 已安装,环境检查已更新。', + loopxInstallFailed: 'LoopX 安装失败:{message}', + loopxRepairTitle: 'LoopX 版本需要修复', + loopxRepairDetail: '当前 {current},此功能需要 0.5.1。安装到 BitFun 管理目录,不会修改系统版本。', + loopxInstallingTitle: '正在准备 LoopX 0.5.1', + loopxInstallingDetail: '仅下载运行所需源码并校验版本,完成后会自动重新检查环境。', + tasks: '任务', + collapseTasks: '收起任务栏', + resizeTasks: '调整任务栏宽度', + resizeIssueColumns: '调整详情和时间线宽度', + expandTasks: '展开任务栏', + noTasks: '暂无任务', + emptyNoTask: '暂无任务', + emptyNoTaskHint: '在上方粘贴 GitHub Issue 或 Pull Request 链接创建修复任务;运行过程会实时展示在这里。', + followBanner: '自动跟随:{item} · {state}', + followBannerHint: '正在展示运行中的任务;点击左侧任务可固定查看该 Issue', + backToFollow: '恢复自动跟随', + timelineTitle: '运行时间线', + timelineLiveScope: '实时 · {item}', + timelineIdleScope: '已固定 · {item}', + worktreeQuiet: '正在准备 Worktree:{item}。首次克隆可能需要几分钟;Git 静默时不会产生子进程输出。', + noLogs: '暂无运行事件', + noLiveOutput: '暂无实时模型输出', + awaitingFirstOutput: '模型已启动,正在等待首段输出…', + preparingElapsed: '已等待 {duration}', + reviewDecision: '去处理', + summaryTitle: '最新进展', + summaryEmpty: 'Agent 完成本轮回合后,结论会保存在这里。', + factsWorkspace: '工作区', + factsTurn: '回合', + factsReceipt: '结算回执', + factsModel: '模型', + factsArtifacts: '产出物', + techDetailsTitle: '技术详情', + factsArtifactNone: '本轮暂无文件变更', + errorTitle: '错误', + gateKindPublish: '发布审批', + gateKindDecision: '决策请求', + outputUnavailable: '实时输出暂不可用', + outputThinking: '思考', + outputThinkingSummary: '思考过程 · {value} 字(点击展开)', + decisionCardTitle: '需要你的决策', + decisionCardTitleRecovery: '工作段被中断,需要恢复', + decisionCardTitlePlanExhausted: '修复计划已执行完毕,等待收尾方式', + decisionResume: '恢复重试', + decisionCardGateHint: '请在下方审批面板中批准或拒绝该请求。', + decisionCardRecoveryHint: '本段工作已结束,但结算未能确认持久进展;可恢复重试一次,结论详情见下方最新进展。', + decisionCardPlanExhaustedHint: '流程的待办已全部执行完,但没有留下可继续的待办、待批门禁或收尾声明,宿主不会伪造收尾。已产生的提交、未提交改动与证据均保留在任务工作区。你可以:从任务分支手动推送并开 PR / 在 issue 上评论说明;或等 goal 出现新待办(例如上游 PR 合并、新的监控结论)后再点“恢复重试”。', + summaryVerdictNeedsFix: '🛠️ 需要修复', + summaryVerdictAlreadyFixedUpstream: '✅ 上游已修复', + summaryVerdictWontFix: '🚫 无需修复', + summaryVerdictNeedsInfo: '❓ 信息不足', + summaryReproductionReproduced: '🔁 已复现', + summaryReproductionNotReproduced: '🔁 未复现(未执行复现环节)', + summaryReproductionNotApplicable: '🔁 不适用', + summaryBadgeNote: '徽标表示对该 Issue 的定性(是否需要修复、上游是否已修复等),与任务处理完成度相互独立;代理只负责本地实现与验证,不会自动提交 PR,提交、合并与关闭由你决定。', + summarySegmentEvidence: '调查取证', + summarySegmentRouteDecision: '方案决策', + summarySegmentImplementation: '实现修复', + summarySegmentValidation: '验证', + summarySegmentDelivery: '交付', + summaryCompletedTitle: '本段完成', + summaryDecisionTitle: '已定方案', + summaryRejectedTitle: '已否决选项', + summaryNextStep: '下一步', + summaryBlockers: '阻塞', + summaryTechReceipts: '技术回执', + summaryPendingGate: '⏸️ 等你批准后继续(见上方审批面板)', + recoveryReasonHostRestart: '中断原因:应用异常关闭导致执行中断', + recoveryReasonExecutionFailure: '中断原因:执行过程失败', + recoveryReasonPlanExhausted: '中断原因:计划用尽——无待办、无待批门禁、无终局声明', + recoveryReasonSettlementUnverified: '中断原因:结算未能确认持久进展(写入已验证,花费回执缺失)', + recoveryReasonRepositoryPaused: '中断原因:同仓库其他任务失败后暂停队列', + recoveryReasonManualRestore: '中断原因:手动恢复的归档任务', + outputTool: '工具', + outputModel: '模型', + outputText: '输出', + outputChunks: '{value} 段', + sourceScheduler: '任务调度', + sourceLoopx: 'LoopX 引擎', + sourceAgent: 'Agent', + sourceGit: 'Git', + sourceGithub: 'GitHub', + sourceSystem: '系统', + toolExecCommand: '执行命令', + toolRead: '读取文件', + toolGrep: '搜索内容', + toolLs: '浏览目录', + toolWebFetch: '访问网页', + toolWebSearch: '搜索网页', + toolWrite: '写入文件', + toolEdit: '修改文件', + toolQueued: '工具已排队:{tool}', + toolWaiting: '工具等待中:{tool}', + toolStarted: '正在运行:{tool}', + toolConfirmation: '工具等待确认:{tool}', + toolConfirmed: '已确认工具:{tool}', + toolRejected: '已拒绝工具:{tool}', + toolCompleted: '工具完成:{tool}', + toolFailed: '工具失败:{tool}', + toolCancelled: '工具已取消:{tool}', + toolStateQueued: '排队', + toolStateWaiting: '等待', + toolStateStarted: '运行中', + toolStateConfirmation: '等待确认', + toolStateConfirmed: '已确认', + toolStateRejected: '已拒绝', + toolStateCompleted: '已完成', + toolStateFailed: '失败', + toolStateCancelled: '已取消', + newEvents: '滚动到最新输出', + confirmTask: '确认任务', + repository: '仓库', + workspace: '工作区', + workspace_existing_worktree: '使用现有 Worktree', + workspace_new_worktree: '将创建独立 Worktree', + workspace_clone_required: '将克隆并创建 Worktree', + workspace_unavailable: '工作区不可用', + imageCapability: '图片能力', + supported: '支持', + unsupported: '不支持', + items: 'Issue / PR', + permissions: '本次权限', + explicitGrant: '逐项授权', + cancel: '取消', + close: '关闭', + createTasks: '创建所选任务', + newAttempt: '新尝试', + terminalExists: '已有终态任务', + confirmNewAttempt: '确认新尝试', + decisionRequired: '需要你的决定', + systemNotificationTitle: 'BitFun LoopX 需要你的决定 · {label}', + afterApprove: '批准后', + afterReject: '拒绝后', + approvalNote: '审批备注', + approvalNotePlaceholder: '补充批准或拒绝的原因(可选)', + reject: '拒绝', + approve: '批准', + pause: '暂停', + resume: '恢复', + resumeRepository: '恢复仓库任务({value})', + resumeTargetMissing: '恢复目标已失效,请刷新任务列表后重试', + resumingRepository: '正在恢复异常任务…', + repositorySerial: '同仓库串行执行', + batchAction: '批量操作', + resumeRepositoryTitle: '恢复此仓库的异常任务', + confirmContinue: '确认继续', + resumeRepositoryMessage: '将恢复 {repository} 中 {value} 个已暂停、中止或失败的任务。同一时间只运行 1 个,其余任务进入队列。', + resumeRepositoryApplied: '已将 {value} 个仓库任务加入队列。', + repositoryPausedByModel: '模型请求失败,仓库队列已暂停', + archive: '归档并清理工作区', + restore: '还原', + updated: '更新于 {duration} 前', + openInGithub: '在 GitHub 中打开', + currentWork: '当前', + outcomeUpdated: '{duration}前更新', + stagePending: '待开始', + stageActive: '进行中', + stageComplete: '已完成', + stageBlocked: '已阻塞', + progressSummaryLine: '修复分五步:准备工作区 → 分析与方案 → 实施修改 → 结果核验 → 结算收束。当前:{stage}。', + progressPreparing: '正在准备独立 Worktree', + progressQueued: '等待同仓库前序 Issue', + progressAnalyzing: '正在分析原因并形成可执行方案', + progressImplementing: '已进入代码修改阶段', + progressValidating: '正在核验本轮产出', + progressSettling: '正在保存本轮进展', + progressWaiting: '等待你的决定', + progressRecovery: '本轮执行已中断', + progressCompleted: '修复流程已完成', + progressResolvedUpstream: '上游已处理该问题', + progressResolvedUpstreamDetail: '已确认当前上游代码移除了原始故障路径,不需要再提交额外修复。', + progressIdle: '等待任务推进', + issueDescription: 'Issue 描述', + loadingIssueDescription: '正在加载 Issue 描述…', + issueDescriptionUnavailable: '暂时无法加载 Issue 描述。', + publishApprovalTitle: '是否发布修复并创建 Pull Request?', + publishApprovalSummary: '修复已在分支 {branch} 的提交 {commit} 中准备完成,目标仓库为 {repository}。现在需要你决定是否发布。', + publishApprovalSummaryGeneric: '修复和发布材料已经准备完成,目标仓库为 {repository}。现在需要你决定是否发布为 Pull Request。', + publishApprovalApproveEffect: '推送修复分支并创建 Pull Request,随后进入 macOS 真机验证。批准不会自动合并代码。', + publishApprovalRejectEffect: '不推送分支,也不创建 Pull Request;本地分支、提交和验证结果会保留,任务停在当前步骤。', + publishApprovalRecommendationReady: '建议批准:当前修改已有验证结果,批准后仍可在 Pull Request 中继续评审,并不会自动合并。', + publishApprovalRecommendationReview: '建议先确认修改和验证结果;批准只会发布 Pull Request,不会自动合并。', + publishApprovalApprove: '批准并创建 PR', + publishApprovalReject: '暂不发布', + genericApprovalTitle: '是否继续处理这个 Issue?', + genericApprovalSummary: 'Agent 在执行任务时请求一个决定。需要你批准的是超出只读边界的动作(写入/提交、构建、安装、发布、真实运行验证等);仅在本地文件内修改不需要审批。具体内容见下方原始请求,不确定时可以先在时间线里确认它做了什么再决定。', + genericApprovalApproveEffect: '批准后:按下方「原始请求」执行其中的具体操作(含对仓库的写入/提交,以及构建、安装、发布、真实运行验证等外部动作),完成后会再次汇报结果。', + genericApprovalRejectEffect: '拒绝后:不执行该操作,任务保持等待、不会继续推进;现有修改、调查结果和工作区都会保留。', + genericApprovalRecommendation: '建议:先展开「原始请求」确认要执行的每个动作——需要你批准的是写入/提交、构建、安装、真实运行验证等会改变仓库或产生外部副作用的步骤;文件内的普通修改不需要审批。确认符合预期后再继续,不确定时暂不执行并在备注中说明需要补充的信息。', + gateRawDetails: '原始请求(来自 Agent)', + gateGrantAuthorityScopes: '需要的权限:{scopes}。', + gateGatedReadTitle: '允许读取 Issue 正文与维护者评论?', + gateGatedReadSummary: 'Agent 目前只能看到这条 Issue 的元数据(标题、标签、状态)。要判断它是否值得修复、是否已经有人处理过,需要进一步读取正文和评论内容。这些内容仅用于本任务的分析,不会原样写入公开状态。', + gateGatedReadApproveEffect: 'Agent 将读取该 Issue 的正文与维护者评论,继续「是否已有人修复」的证据评估,然后汇报结论或继续修复。', + gateGatedReadRejectEffect: 'Agent 不会读取任何正文内容,任务保持等待。你也可以在备注中直接粘贴关键信息后再批准。', + gateGatedReadApprove: '允许读取', + gateGatedReadReject: '暂不读取', + gateClarifyTitle: '维护者反馈存在歧义,需要你澄清方向', + gateClarifySummary: '维护者对该修复提出的修改要求存在多种理解方式,Agent 无法确定预期行为,需要你给出明确方向。原始反馈见下方。', + gateClarifyApproveEffect: '批准后请在备注中写清预期的行为或取舍,Agent 会按你的说明继续修改。', + gateReuseMergeTitle: '是否合并已有 PR 作为本 Issue 的解决方案?', + gateReuseMergeSummaryWithPr: 'Agent 评估认为已有 {pr}({title})可直接解决当前 Issue,验证证据齐全,无需重复实现;合并前请确认 PR 归属与合并权限。', + gateReuseMergeSummary: 'Agent 评估认为已有 PR 可直接解决当前 Issue,验证证据齐全,无需重复实现;合并前请确认 PR 归属与合并权限。', + gateReuseMergeApproveEffect: '批准后不会为当前 Issue 提交新补丁或新 PR;将复用 {pr} 作为解决方案并执行合并,之后继续跟进该 PR 的合并/关闭状态,直到本任务收尾。', + gateReuseMergeRejectEffect: '本轮不合并 {pr},任务不再进入后续步骤;已完成的评估、工作区与证据全部保留。可在备注说明理由,或要求 Agent 改为独立补丁方案重新评估。', + gateReuseMergeRecommendation: '建议:确认 PR 内容与合并时机符合预期后再批准;合并是对上游仓库的对外动作。', + gateReuseMergeApprove: '批准合并', + gateReuseMergeReject: '拒绝', + gateReuseMergeFallbackPr: '已有 PR', + gateClarifyRejectEffect: '本次不处理该反馈,任务保持等待;维护者后续补充说明后可以再次处理。', + gateGrantAuthorityTitle: '需要授予额外写权限', + gateGrantAuthoritySummary: '维护者的修改要求已被理解,但执行它需要的写权限当前未授权。请确认范围后决定是否授予。', + gateGrantAuthorityApproveEffect: 'Agent 将以授予的权限应用维护者的修改要求,完成后汇报结果。', + gateGrantAuthorityRejectEffect: '不授予权限;Agent 会把该要求记录为阻塞项并保持等待。', + gateDraftReadyTitle: '将 Draft PR 标记为「准备评审」?', + gateDraftReadySummary: '修复 PR 目前是草稿状态。是否标记为 ready for review 并进入评审流程由你决定。', + gateDraftReadyApproveEffect: 'PR 将标记为 ready for review,随后按仓库政策邀请评审人。', + gateDraftReadyRejectEffect: 'PR 保持草稿状态,继续监控;你可以稍后再批准。', + justNow: '刚刚', + seconds: '{value} 秒', + minutes: '{value} 分钟', + hours: '{value} 小时', + days: '{value} 天', + intakeUnavailable: '当前执行位置不支持创建任务。', + bridgeUnavailable: '宿主没有提供受信任的 LoopX 运行接口。请更新 BitFun 后重试。', + selectAtLeastOne: '至少选择一个仍开放的 Issue 或 PR。', + selectAll: '全选', + selectPermissions: '请确认全部必需权限范围后再创建任务。', + previewExpired: '确认信息已失效,请重新分析链接。', + taskCreated: '任务已创建。', + tasksCreated: '已创建 {value} 个任务。', + outcomeCount: '{message}({value} 项)', + selectedCandidates: '已选 {selected} / {total}', + batchSelection: '将为已选的 {value} 个不同 Issue / PR 分别创建独立任务,工作区会逐个准备。', + workspacePreparationFailed: '工作区准备失败', + queuedRepoBusy: '等待同仓库当前任务完成', + queuedBoundedWait: '回合之间等待调度(有界回合)', + openedExisting: '已打开现有任务,没有重复创建。', + closedNoop: '目标已关闭或合并,无需创建任务。', + liveVerification: '目标需要再次在线复核,暂未创建任务。', + retryRequired: '该目标已有终态任务。只有确认后才会创建新的 attempt。', + actionApplied: '操作已应用。', + approvalSubmitting: '正在提交审批决定,任务会在宿主确认后继续。', + approvalSubmittingShort: '正在提交决定…', + actionPending: '正在提交操作', + pausePending: '正在暂停', + resumePending: '正在继续', + archivePending: '正在归档', + actionDuplicate: '该操作已经应用,无需重复执行。', + revisionConflict: '任务状态已经变化,已刷新到最新版本。', + actionRejected: '宿主拒绝了该操作。', + noGate: '没有找到可回答的审批门禁,请刷新任务状态。', + approvalNeeded: '任务正在等待远程可回答的审批。', + activityInstallingDependencies: '正在准备项目依赖', + activityBuildingInstaller: '正在构建 Windows 安装包', + activityTestingUpgrade: '正在验证安装器升级链路', + activityWaitingProcess: '正在等待外部进程返回结果', + activitySyncingProgress: '正在同步工作进展', + activityCheckingRepository: '正在检查仓库状态', + activityRunningCommand: '正在执行项目命令', + truncatedCandidates: '候选项已截断,请缩小仓库范围后重新分析。', + imageWarning: '所选内容包含图片,但当前模型不支持图片输入。', + modelUnavailable: '当前模型不可用,请返回并选择其他模型。', + workspaceUnavailable: '宿主无法为该仓库准备受信任的 Worktree。', + resolvedItem: '已处理', + openItem: '开放', + fromRepository: '仓库候选', + taskNumber: '任务 {value}', + sidecar: 'LoopX 引擎', + gitWorktree: 'Git / Worktree', + agentModel: 'Agent 模型', + pythonFallback: 'Python 备用', + githubAuth: 'GitHub 登录', + status_unknown: '未知', + status_checking: '检查中', + status_available: '可用', + status_degraded: '已降级', + status_unavailable: '不可用', + status_ready: '就绪', + status_blocked: '阻塞', + state_preparing: '准备中', + state_queued: '排队中', + state_running: '运行中', + state_waiting_for_user: '待批准', + state_retry_wait: '等待重试', + state_cancelling: '正在停止', + state_stopped: '已暂停', + state_recovery_required: '待恢复', + state_completed: '已完成', + state_failed: '失败', + state_archived: '已归档', + state_resolved_upstream: '上游已修复', + phase_unknown: '等待宿主状态', + phase_validating_environment: '验证环境', + phase_resolving_intake: '复核输入', + phase_preparing_workspace: '准备独立工作区', + phase_creating_goal: '准备任务目标', + phase_queued: '等待调度', + phase_inspecting_goal: '检查任务目标', + phase_building_turn: '正在准备下一阶段', + phase_starting_agent: '启动修复任务', + phase_agent_running: '正在分析或修改', + phase_validating_progress: '正在核验结果', + phase_settling_turn: '正在保存进展', + phase_waiting_for_approval: '等待审批', + phase_retry_backoff: '重试退避', + phase_cancelling: '正在取消', + phase_recovering: '恢复并同步', + phase_finished: '流程结束', + monitor_phase_queued: 'PR 监控等待中', + monitor_chip: 'PR 监控', + monitor_waiting_detail: 'LoopX 心跳按自己的节奏检查 CI、Review 与新评论;这段等待是交付闭环的正常状态,不会消耗模型额度。', + monitor_next_check: '下次检查', + scope_workspace_read: '读取工作区', + scope_workspace_write: '修改工作区', + scope_git_local: '本地 Git 操作', + scope_github_read: '读取 GitHub', + scope_agent_execution: '运行 Agent', + scope_publish: '发布变更', + scope_public_comment: '公开评论', + scope_pull_request: '创建 Pull Request', + scope_merge: '合并 Pull Request', + scope_production_action: '生产环境操作', + scopeHighRisk: '需要单独确认的外部副作用', + scopeStandard: '本次修复所需能力', + }, + 'en-US': { + skipToLogs: 'Skip to logs', + connecting: 'Connecting to host', + connected: 'Connected', + connectionFailed: 'Connection failed', + intakeLabel: 'GitHub issue, pull request, or repository URL', + intakePlaceholder: 'Paste a GitHub issue, PR, repository, or issues-list URL', + model: 'Model', + modelAuto: 'Automatic model', + modelLoading: 'Loading models...', + modelEmpty: 'No enabled text models found', + modelLoadFailed: 'Model list failed to load', + modelReloadTitle: 'Refresh model list', + modelSelectionChanged: 'Model changed. Analyze the URL again.', + modelPrimaryTag: 'Primary', + resolve: 'Analyze URL', + resolving: 'Verifying URL against the live source', + resetLoopx: 'Reset LoopX', + resettingLoopxBackground: 'Cleaning tasks and saved progress in the background. You can keep using this window; it refreshes when cleanup finishes.', + destructiveAction: 'Destructive action', + resetLoopxTitle: 'Clear and start over', + resetLoopxMessage: 'Stop and delete {tasks} tasks, {events} log events, all saved progress, and all managed workspaces. This cannot be undone.', + resetLoopxRetained: 'Model configuration, GitHub login, MiniApp settings, and clean Git object caches are retained. Unsettled worktrees are not reused.', + resetLoopxConfirm: 'Clear and start over', + resetLoopxApplied: 'LoopX was cleared. You can start a fresh test.', + unsupportedTitle: 'LoopX is unavailable in this execution location', + unsupportedDefault: 'LoopX currently supports local Desktop workspaces only. Remote workspaces will not silently run on this device instead.', + environment: 'Environment', + coreEnvironment: 'Core environment', + optionalEnvironment: 'Optional capabilities', + required: 'Required', + optional: 'Optional', + retryEnvironment: 'Check environment again', + installLoopx: 'Install compatible version', + loopxInstallStarted: 'Downloading and verifying LoopX v0.5.1 from the official GitHub source repository...', + loopxInstallQueued: 'Installation started in the background. You can keep using this window.', + loopxInstallComplete: 'LoopX v0.5.1 is installed and the environment check is up to date.', + loopxInstallFailed: 'LoopX installation failed: {message}', + loopxRepairTitle: 'LoopX needs a compatible version', + loopxRepairDetail: 'Current: {current}. This feature requires 0.5.1. Installation stays inside BitFun and does not change the system version.', + loopxInstallingTitle: 'Preparing LoopX 0.5.1', + loopxInstallingDetail: 'Downloading only the runtime source and verifying it. The environment will be checked automatically when finished.', + tasks: 'Tasks', + collapseTasks: 'Collapse task rail', + resizeTasks: 'Resize task rail', + resizeIssueColumns: 'Resize detail and timeline', + expandTasks: 'Expand task rail', + noTasks: 'No tasks yet', + emptyNoTask: 'No tasks yet', + emptyNoTaskHint: 'Paste a GitHub issue or pull request URL above to start a repair task; its progress streams here in real time.', + followBanner: 'Auto-following: {item} · {state}', + followBannerHint: 'Showing the running task; select a task on the left to pin it', + backToFollow: 'Resume auto-follow', + timelineTitle: 'Run timeline', + timelineLiveScope: 'Live · {item}', + timelineIdleScope: 'Pinned view', + worktreeQuiet: 'Preparing worktree: {item}. The first clone can take a few minutes; Git may not emit output while it is working.', + noLogs: 'No run events yet', + noLiveOutput: 'No live model output yet', + awaitingFirstOutput: 'The model has started; waiting for its first output…', + preparingElapsed: 'Waiting for {duration}', + reviewDecision: 'Review', + summaryTitle: 'Latest progress', + summaryEmpty: 'Once the Agent finishes this turn, its conclusions are saved here.', + factsWorkspace: 'Workspace', + factsTurn: 'Turn', + factsReceipt: 'Settlement receipt', + factsModel: 'Model', + factsArtifacts: 'Artifacts', + techDetailsTitle: 'Technical details', + factsArtifactNone: 'No file changes in this turn yet', + errorTitle: 'Error', + gateKindPublish: 'Publish approval', + gateKindDecision: 'Decision request', + outputUnavailable: 'Live output is unavailable', + outputThinking: 'Thinking', + outputThinkingSummary: 'Thinking · {value} chars (click to expand)', + decisionCardTitle: 'Needs your decision', + decisionCardTitleRecovery: 'Work segment was interrupted and needs recovery', + decisionCardTitlePlanExhausted: 'Fix plan completed; choose how to finish', + decisionResume: 'Resume retry', + decisionCardGateHint: 'Approve or reject the request in the approval panel below.', + decisionCardRecoveryHint: 'This segment finished but settlement could not validate durable progress. You can retry recovery once; see the summary below for the conclusion.', + decisionCardPlanExhaustedHint: 'All plan todos are done, but the flow left no open todo, approval gate, or terminal declaration, and the host will not fabricate one. Commits, uncommitted changes, and evidence are preserved in the task worktree. You can push the task branch and open a PR / comment on the issue yourself, or wait until the goal gains a new todo or gate (for example after an upstream PR merge) and then use Resume retry.', + summaryVerdictNeedsFix: '🛠️ Needs fix', + summaryVerdictAlreadyFixedUpstream: '✅ Already fixed upstream', + summaryVerdictWontFix: '🚫 Won\'t fix', + summaryVerdictNeedsInfo: '❓ Needs info', + summaryReproductionReproduced: '🔁 Reproduced', + summaryReproductionNotReproduced: '🔁 Not reproduced (no repro step)', + summaryReproductionNotApplicable: '🔁 Not applicable', + summaryBadgeNote: 'Badges qualify the issue (needs fix / fixed upstream / wont-fix) and are independent of task completion state; the agent only implements and validates locally, never opens a PR - submit, merge and close stay host actions.', + summarySegmentEvidence: 'Evidence', + summarySegmentRouteDecision: 'Route decision', + summarySegmentImplementation: 'Implementation', + summarySegmentValidation: 'Validation', + summarySegmentDelivery: 'Delivery', + summaryCompletedTitle: 'This segment', + summaryDecisionTitle: 'Decided', + summaryRejectedTitle: 'Rejected options', + summaryNextStep: 'Next step', + summaryBlockers: 'Blockers', + summaryTechReceipts: 'Technical receipts', + summaryPendingGate: '⏸️ Waiting for your approval (see the approval panel above)', + recoveryReasonHostRestart: 'Interrupted by an abnormal app shutdown', + recoveryReasonExecutionFailure: 'Interrupted by an execution failure', + recoveryReasonPlanExhausted: 'Interrupted because the plan ran dry: no open todo, no approval gate, no terminal declaration', + recoveryReasonSettlementUnverified: 'Interrupted because settlement could not validate durable progress (writeback verified, quota spend receipt missing)', + recoveryReasonRepositoryPaused: 'Interrupted because the repository queue paused after another task failed', + recoveryReasonManualRestore: 'Interrupted because an archived task was manually restored', + outputTool: 'Tool', + outputModel: 'Model', + outputText: 'Output', + outputChunks: '{value} chunks', + sourceScheduler: 'Task scheduler', + sourceLoopx: 'LoopX engine', + sourceAgent: 'Agent', + sourceGit: 'Git', + sourceGithub: 'GitHub', + sourceSystem: 'System', + toolExecCommand: 'Run command', + toolRead: 'Read file', + toolGrep: 'Search content', + toolLs: 'Browse directory', + toolWebFetch: 'Fetch web page', + toolWebSearch: 'Search web', + toolWrite: 'Write file', + toolEdit: 'Edit file', + toolQueued: 'Tool queued: {tool}', + toolWaiting: 'Tool waiting: {tool}', + toolStarted: 'Running: {tool}', + toolConfirmation: 'Tool needs confirmation: {tool}', + toolConfirmed: 'Tool confirmed: {tool}', + toolRejected: 'Tool rejected: {tool}', + toolCompleted: 'Tool completed: {tool}', + toolFailed: 'Tool failed: {tool}', + toolCancelled: 'Tool cancelled: {tool}', + toolStateQueued: 'Queued', + toolStateWaiting: 'Waiting', + toolStateStarted: 'Running', + toolStateConfirmation: 'Needs confirmation', + toolStateConfirmed: 'Confirmed', + toolStateRejected: 'Rejected', + toolStateCompleted: 'Completed', + toolStateFailed: 'Failed', + toolStateCancelled: 'Cancelled', + newEvents: 'Scroll to latest output', + confirmTask: 'Confirm task', + repository: 'Repository', + workspace: 'Workspace', + workspace_existing_worktree: 'Use existing worktree', + workspace_new_worktree: 'Create an isolated worktree', + workspace_clone_required: 'Clone and create a worktree', + workspace_unavailable: 'Workspace unavailable', + imageCapability: 'Image support', + supported: 'Supported', + unsupported: 'Unsupported', + items: 'Issue / PR', + permissions: 'Permissions for this run', + explicitGrant: 'Explicit grant', + cancel: 'Cancel', + close: 'Close', + createTasks: 'Create selected tasks', + newAttempt: 'New attempt', + terminalExists: 'A terminal task already exists', + confirmNewAttempt: 'Confirm new attempt', + decisionRequired: 'Your decision is required', + systemNotificationTitle: 'BitFun LoopX needs your decision · {label}', + afterApprove: 'If approved', + afterReject: 'If rejected', + approvalNote: 'Approval note', + approvalNotePlaceholder: 'Optional reason for approving or rejecting', + reject: 'Reject', + approve: 'Approve', + pause: 'Pause', + resume: 'Resume', + resumeRepository: 'Recover repository tasks ({value})', + resumeTargetMissing: 'Resume target is stale; refresh the task list and retry', + resumingRepository: 'Recovering failed tasks...', + repositorySerial: 'Runs serially per repository', + batchAction: 'Batch action', + resumeRepositoryTitle: 'Recover repository failures', + confirmContinue: 'Continue tasks', + resumeRepositoryMessage: 'Recover {value} paused, interrupted, or failed tasks in {repository}. One task runs at a time; the rest remain queued.', + resumeRepositoryApplied: 'Queued {value} repository tasks.', + repositoryPausedByModel: 'Model request failed; repository queue paused', + archive: 'Archive & clean workspace', + restore: 'Restore', + updated: 'Updated {duration} ago', + openInGithub: 'Open in GitHub', + currentWork: 'Current', + outcomeUpdated: 'Updated {duration} ago', + stagePending: 'Pending', + stageActive: 'Active', + stageComplete: 'Complete', + stageBlocked: 'Blocked', + progressSummaryLine: 'The repair runs five stages: worktree → analysis and plan → implementation → validation → settlement. Current: {stage}.', + progressPreparing: 'Preparing an isolated worktree', + progressQueued: 'Waiting for the previous repository issue', + progressAnalyzing: 'Analyzing the cause and forming an actionable plan', + progressImplementing: 'Code changes are in progress', + progressValidating: 'Validating this outcome', + progressSettling: 'Saving this stage of progress', + progressWaiting: 'Waiting for your decision', + progressRecovery: 'Execution was interrupted', + progressCompleted: 'The repair workflow is complete', + progressResolvedUpstream: 'Resolved upstream', + progressResolvedUpstreamDetail: 'The current upstream code has removed the original failure path, so no additional patch is required.', + progressIdle: 'Waiting for task progress', + issueDescription: 'Issue description', + loadingIssueDescription: 'Loading issue description...', + issueDescriptionUnavailable: 'Issue description is temporarily unavailable.', + publishApprovalTitle: 'Publish the fix and create a pull request?', + publishApprovalSummary: 'The fix is prepared on branch {branch} at commit {commit} for {repository}. Your approval is required before publishing it.', + publishApprovalSummaryGeneric: 'The fix and publishing materials are ready for {repository}. Your approval is required before creating the pull request.', + publishApprovalApproveEffect: 'Push the fix branch, create a pull request, then continue with macOS host verification. Approval does not merge code automatically.', + publishApprovalRejectEffect: 'Do not push the branch or create a pull request. Keep the local branch, commit, and validation results, and stop at this step.', + publishApprovalRecommendationReady: 'Recommended: approve. The change has validation results and remains reviewable in the pull request; it will not be merged automatically.', + publishApprovalRecommendationReview: 'Review the change and validation results first. Approval publishes a pull request but does not merge it automatically.', + publishApprovalApprove: 'Approve and create PR', + publishApprovalReject: 'Keep local only', + genericApprovalTitle: 'Continue handling this Issue?', + genericApprovalSummary: 'The agent requested a decision while working. What needs your approval are actions beyond the read-only boundary (writes/commits, builds, installs, publishing, real-run validation); plain local file edits do not need approval. See the original request below; when unsure, check the timeline first to see what it did.', + genericApprovalApproveEffect: 'Approve = perform the concrete operation described in the "Original request" below (writes/commits in the repo, plus external actions such as building, installing, publishing, or real-run validation), then report results again afterward.', + genericApprovalRejectEffect: 'Reject = do not perform that operation; the task stays waiting and does not move forward. Existing changes, investigation results, and the workspace are kept.', + genericApprovalRecommendation: 'Recommendation: expand the "Original request" and confirm each step. Only steps that change the repo or produce external side effects (write/commit, build, install, real-run validation) need your approval; ordinary local file edits do not. Continue when it matches your expectation; otherwise pause and note what information is missing.', + gateRawDetails: 'Original request (from agent)', + gateGrantAuthorityScopes: 'Required scopes: {scopes}.', + gateGatedReadTitle: 'Allow reading the issue body and maintainer comments?', + gateGatedReadSummary: 'The agent can only see metadata (title, labels, state) so far. To judge whether this issue is worth fixing and whether someone already handled it, it needs to read the issue body and comments. That content is only used for this task\'s analysis and is never copied into public state.', + gateGatedReadApproveEffect: 'The agent will read the issue body and maintainer comments, continue the prior-work evidence check, then report a conclusion or continue the fix.', + gateGatedReadRejectEffect: 'No content will be read and the task stays waiting. You can also paste key details in the note and approve.', + gateGatedReadApprove: 'Allow reading', + gateGatedReadReject: 'Not now', + gateClarifyTitle: 'Maintainer feedback is ambiguous — clarification needed', + gateClarifySummary: 'The maintainer\'s requested change can be interpreted in multiple ways; the agent cannot determine the intended behavior and needs your direction. The original feedback is below.', + gateClarifyApproveEffect: 'After approving, describe the intended behavior or tradeoff in the note; the agent will continue accordingly.', + gateReuseMergeTitle: 'Merge the existing PR as this issue\'s solution?', + gateReuseMergeSummaryWithPr: 'The agent evaluated existing {pr} ({title}) as already fixing this issue with solid verification evidence; no duplicate implementation is needed. Confirm PR ownership and merge authority before approving.', + gateReuseMergeSummary: 'The agent evaluated an existing PR as already fixing this issue with solid verification evidence; no duplicate implementation is needed. Confirm PR ownership and merge authority before approving.', + gateReuseMergeApproveEffect: 'No new patch or PR will be submitted for this issue; {pr} will be reused as the solution and merged. The task keeps tracking that PR\'s merge/close state until it closes out.', + gateReuseMergeRejectEffect: 'This round will not merge {pr} and the task will not proceed; the evaluation, workspace, and evidence are preserved. Note a reason, or ask the agent for an independent patch route instead.', + gateReuseMergeRecommendation: 'Recommendation: approve only when the PR content and merge timing match your expectations; merging is an external action on the upstream repository.', + gateReuseMergeApprove: 'Approve merge', + gateReuseMergeReject: 'Reject', + gateReuseMergeFallbackPr: 'the existing PR', + gateClarifyRejectEffect: 'The feedback will not be processed for now and the task stays waiting; it can be revisited after the maintainer clarifies.', + gateGrantAuthorityTitle: 'Additional write authority required', + gateGrantAuthoritySummary: 'The maintainer\'s requested change is understood, but applying it requires write authority that is not currently granted. Confirm the scope before deciding.', + gateGrantAuthorityApproveEffect: 'The agent will apply the maintainer\'s change with the granted authority and report back when done.', + gateGrantAuthorityRejectEffect: 'Authority will not be granted; the agent records the request as blocked and keeps waiting.', + gateDraftReadyTitle: 'Mark the draft PR as ready for review?', + gateDraftReadySummary: 'The fix PR is still a draft. Decide whether to mark it ready for review and start the review process.', + gateDraftReadyApproveEffect: 'The PR will be marked ready for review and reviewers invited per repository policy.', + gateDraftReadyRejectEffect: 'The PR stays a draft and monitoring continues; you can approve later.', + justNow: 'just now', + seconds: '{value}s', + minutes: '{value}m', + hours: '{value}h', + days: '{value}d', + intakeUnavailable: 'Tasks cannot be created from this execution location.', + bridgeUnavailable: 'The host did not expose the trusted LoopX runtime interface. Update BitFun and try again.', + selectAtLeastOne: 'Select at least one open issue or pull request.', + selectAll: 'Select all', + selectPermissions: 'Confirm every required permission scope before creating the task.', + previewExpired: 'This preview is stale. Analyze the URL again.', + taskCreated: 'Task created.', + tasksCreated: '{value} tasks created.', + outcomeCount: '{message} ({value} items)', + selectedCandidates: '{selected} / {total} selected', + batchSelection: 'Each of the {value} selected issues or pull requests will become a separate task. Workspaces are prepared one at a time.', + workspacePreparationFailed: 'Workspace setup failed', + queuedRepoBusy: 'Waiting for the active task in this repository', + queuedBoundedWait: 'Between bounded turns', + openedExisting: 'Opened the existing task without creating a duplicate.', + closedNoop: 'The target is closed or merged; no task was created.', + liveVerification: 'The target needs another live verification before a task can be created.', + retryRequired: 'A terminal task exists. Confirm before creating a new attempt.', + actionApplied: 'Action applied.', + approvalSubmitting: 'Submitting the decision. The task will continue after host confirmation.', + approvalSubmittingShort: 'Submitting decision...', + actionPending: 'Applying action', + pausePending: 'Pausing', + resumePending: 'Continuing', + archivePending: 'Archiving', + actionDuplicate: 'This action was already applied.', + revisionConflict: 'Task state changed. The latest snapshot has been loaded.', + actionRejected: 'The host rejected this action.', + noGate: 'No answerable approval gate was found. Refresh the task state.', + approvalNeeded: 'The task is waiting at an approval gate that can be answered remotely.', + activityInstallingDependencies: 'Preparing project dependencies', + activityBuildingInstaller: 'Building the Windows installer', + activityTestingUpgrade: 'Validating the installer upgrade path', + activityWaitingProcess: 'Waiting for an external process to finish', + activitySyncingProgress: 'Synchronizing durable progress', + activityCheckingRepository: 'Checking repository state', + activityRunningCommand: 'Running a project command', + truncatedCandidates: 'The candidate list was truncated. Narrow the repository scope and analyze again.', + imageWarning: 'Selected content contains images, but the current model does not support image input.', + modelUnavailable: 'The selected model is unavailable. Go back and choose another model.', + workspaceUnavailable: 'The host cannot prepare a trusted worktree for this repository.', + resolvedItem: 'Resolved', + openItem: 'Open', + fromRepository: 'Repository candidate', + taskNumber: 'Task {value}', + sidecar: 'LoopX engine', + gitWorktree: 'Git / Worktree', + agentModel: 'Agent model', + pythonFallback: 'Python fallback', + githubAuth: 'GitHub sign-in', + status_unknown: 'Unknown', + status_checking: 'Checking', + status_available: 'Available', + status_degraded: 'Degraded', + status_unavailable: 'Unavailable', + status_ready: 'Ready', + status_blocked: 'Blocked', + state_preparing: 'Preparing', + state_queued: 'Queued', + state_running: 'Running', + state_waiting_for_user: 'Pending approval', + state_retry_wait: 'Retry wait', + state_cancelling: 'Stopping', + state_stopped: 'Paused', + state_recovery_required: 'Pending recovery', + state_completed: 'Completed', + state_failed: 'Failed', + state_archived: 'Archived', + state_resolved_upstream: 'Resolved upstream', + phase_unknown: 'Waiting for host state', + phase_validating_environment: 'Validating environment', + phase_resolving_intake: 'Resolving intake', + phase_preparing_workspace: 'Preparing an isolated workspace', + phase_creating_goal: 'Preparing the task objective', + phase_queued: 'Waiting for scheduler', + phase_inspecting_goal: 'Reviewing the task objective', + phase_building_turn: 'Building turn', + phase_starting_agent: 'Starting the repair task', + phase_agent_running: 'Analyzing or modifying code', + phase_validating_progress: 'Validating results', + phase_settling_turn: 'Saving progress', + phase_waiting_for_approval: 'Waiting for approval', + phase_retry_backoff: 'Retry backoff', + phase_cancelling: 'Cancelling', + phase_recovering: 'Recovering and syncing', + phase_finished: 'Finished', + monitor_phase_queued: 'PR monitor waiting', + monitor_chip: 'PR monitor', + monitor_waiting_detail: 'The LoopX heartbeat checks CI, review, and new comments on its own cadence; waiting here is a normal part of the delivery loop and does not consume model quota.', + monitor_next_check: 'Next check', + scope_workspace_read: 'Read workspace', + scope_workspace_write: 'Modify workspace', + scope_git_local: 'Local Git operations', + scope_github_read: 'Read GitHub', + scope_agent_execution: 'Run agent', + scope_publish: 'Publish changes', + scope_public_comment: 'Post public comments', + scope_pull_request: 'Create pull requests', + scope_merge: 'Merge pull requests', + scope_production_action: 'Production actions', + scopeHighRisk: 'External side effect requiring separate confirmation', + scopeStandard: 'Capability required for this run', + }, +}; + +const view = { + root: byId('loopx-app'), + connectionLabel: byId('connection-label'), + intakeForm: byId('intake-form'), + intakeInput: byId('intake-input'), + intakeHistory: byId('intake-history'), + modelSelect: byId('model-select'), + resolveButton: byId('resolve-button'), + resetLoopx: byId('reset-loopx'), + notice: byId('notice'), + unsupportedBanner: byId('unsupported-banner'), + unsupportedReason: byId('unsupported-reason'), + approvalAlert: byId('approval-alert'), + approvalAlertTitle: byId('approval-alert-title'), + approvalAlertMessage: byId('approval-alert-message'), + approvalAlertOpen: byId('approval-alert-open'), + approvalAlertOpenAction: byId('approval-alert-open-action'), + environmentPanel: byId('environment-panel'), + environmentDot: byId('environment-dot'), + environmentStatus: byId('environment-status'), + environmentChecked: byId('environment-checked'), + environmentRemediation: byId('environment-remediation'), + environmentRemediationTitle: byId('environment-remediation-title'), + environmentRemediationDetail: byId('environment-remediation-detail'), + environmentRemediationProgress: byId('environment-remediation-progress'), + installLoopx: byId('install-loopx'), + installLoopxLabel: byId('install-loopx-label'), + coreEnvironmentList: byId('core-environment-list'), + optionalEnvironmentList: byId('optional-environment-list'), + retryEnvironment: byId('retry-environment'), + taskRail: byId('task-rail'), + railSplitter: byId('rail-splitter'), + collapseTasks: byId('collapse-tasks'), + taskCount: byId('task-count'), + repositoryActions: byId('repository-actions'), + resumeRepository: byId('resume-repository'), + repositoryActionsMeta: byId('repository-actions-meta'), + taskItems: byId('task-items'), + taskEmpty: byId('task-empty'), + issueWorkspace: byId('log-workspace'), + followBanner: byId('follow-banner'), + followBannerText: byId('follow-banner-text'), + issueEmpty: byId('issue-empty'), + issueView: byId('issue-view'), + issueTitle: byId('issue-title'), + issueStatePill: byId('issue-state-pill'), + issueLink: byId('issue-link'), + issueUpdated: byId('issue-updated'), + issueDetail: byId('issue-detail'), + issueSplitter: byId('issue-splitter'), + issueApprovalPanel: byId('issue-approval-panel'), + issueApprovalRaw: byId('issue-approval-raw'), + issueApprovalRawText: byId('issue-approval-raw-text'), + issueApprovalKind: byId('issue-approval-kind'), + issueApprovalTitle: byId('issue-approval-title'), + issueApprovalMessage: byId('issue-approval-message'), + issueApprovalApproveEffect: byId('issue-approval-approve-effect'), + issueApprovalRejectEffect: byId('issue-approval-reject-effect'), + issueApprovalRecommendation: byId('issue-approval-recommendation'), + issueApprovalNote: byId('issue-approval-note'), + issueApprovalReject: byId('issue-approval-reject'), + issueApprovalApprove: byId('issue-approval-approve'), + issueDecisionCard: byId('issue-decision-card'), + issueSummaryMeta: byId('issue-summary-meta'), + issueSummary: byId('issue-summary'), + issueFacts: byId('issue-facts'), + issueError: byId('issue-error'), + issueDescriptionPanel: byId('issue-description-panel'), + issueDescription: byId('issue-description'), + issueNumber: byId('issue-number'), + timelineScope: byId('timeline-scope'), + taskActions: byId('task-actions'), + logScroll: byId('log-scroll'), + logEmpty: byId('log-empty'), + logEmptyText: byId('log-empty-text'), + logList: byId('log-list'), + newEvents: byId('new-events'), + intakeDialog: byId('intake-dialog'), + intakeConfirmForm: byId('intake-confirm-form'), + intakeDialogTitle: byId('intake-dialog-title'), + previewRepository: byId('preview-repository'), + previewWorkspace: byId('preview-workspace'), + previewModel: byId('preview-model'), + previewImages: byId('preview-images'), + candidateCount: byId('candidate-count'), + candidateSelectAll: byId('candidate-select-all'), + candidateList: byId('candidate-list'), + permissionList: byId('permission-list'), + intakeWarning: byId('intake-warning'), + createButton: byId('create-button'), + retryDialog: byId('retry-dialog'), + retryMessage: byId('retry-message'), + retryCancel: byId('retry-cancel'), + retryConfirm: byId('retry-confirm'), + repositoryResumeDialog: byId('repository-resume-dialog'), + repositoryResumeMessage: byId('repository-resume-message'), + repositoryResumeCancel: byId('repository-resume-cancel'), + repositoryResumeConfirm: byId('repository-resume-confirm'), + resetLoopxDialog: byId('reset-loopx-dialog'), + resetLoopxMessage: byId('reset-loopx-message'), + resetLoopxCancel: byId('reset-loopx-cancel'), + resetLoopxConfirm: byId('reset-loopx-confirm'), +}; + +const state = { + snapshot: null, + events: [], + eventKeys: new Set(), + selectedTaskId: null, + followLogs: true, + expandedThinking: new Set(), + preview: null, + pendingCreate: null, + pendingRetry: null, + approvalTaskId: null, + promptedGateIds: new Set(), + pendingApprovalPrompt: false, + syncing: false, + syncRequested: false, + pendingResumeSignal: false, + lastClockSampleAt: Date.now(), + lastHostSignalAt: Date.now(), + lastReattachAt: 0, + gapRecovery: null, + connected: false, + railCollapsed: false, + issueDetailWidth: null, + intakeHistory: [], + outputHistory: [], + outputKeys: new Set(), + outputCharacters: 0, + turnOutput: { + taskId: null, + turnId: null, + streamId: null, + cursor: 0, + events: [], + message: '', + status: 'not_running', + inFlight: false, + timer: null, + }, + itemMetadata: new Map(), + metadataRequests: new Set(), + tornDown: false, + repositoryResumeTarget: null, + repositoryResumePending: false, + taskActionPending: new Map(), + modelCatalogLoading: false, + modelCatalogLoaded: false, + environmentInstallPending: false, + environmentInstallObserved: false, + environmentInstallRequestId: null, + resetPending: false, +}; + +function localeId() { + const raw = app && typeof app.locale === 'string' ? app.locale : 'en-US'; + return raw.startsWith('zh') ? 'zh-CN' : 'en-US'; +} + +function text(key, values) { + const table = COPY[localeId()] || COPY['en-US']; + let output = table[key] || COPY['en-US'][key] || key; + if (values) { + Object.entries(values).forEach(([name, value]) => { + output = output.replace(new RegExp(`\\{${name}\\}`, 'g'), String(value)); + }); + } + return output; +} + +function applyLocale() { + document.documentElement.lang = localeId(); + document.querySelectorAll('[data-i18n]').forEach((element) => { + element.textContent = text(element.dataset.i18n); + }); + document.querySelectorAll('[data-i18n-placeholder]').forEach((element) => { + element.setAttribute('placeholder', text(element.dataset.i18nPlaceholder)); + }); + document.querySelectorAll('[data-i18n-title]').forEach((element) => { + const value = text(element.dataset.i18nTitle); + element.setAttribute('title', value); + if (element.getAttribute('aria-label')) element.setAttribute('aria-label', value); + }); + view.intakeForm.setAttribute('aria-label', text('intakeLabel')); + view.modelSelect.setAttribute('aria-label', text('model')); + renderAll(); +} + +function normalizeTimestamp(value) { + const number = Number(value || 0); + if (!Number.isFinite(number) || number <= 0) return 0; + return number < 100000000000 ? number * 1000 : number; +} + +function durationLabel(milliseconds) { + const seconds = Math.max(0, Math.floor(milliseconds / 1000)); + if (seconds < 8) return text('justNow'); + if (seconds < 60) return text('seconds', { value: seconds }); + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return text('minutes', { value: minutes }); + const hours = Math.floor(minutes / 60); + if (hours < 24) return text('hours', { value: hours }); + return text('days', { value: Math.floor(hours / 24) }); +} + +function relativeLabel(value) { + const timestamp = normalizeTimestamp(value); + if (!timestamp) return '--'; + return durationLabel(Date.now() - timestamp); +} + +function clockLabel(value) { + const timestamp = normalizeTimestamp(value); + if (!timestamp) return '--:--:--'; + const date = new Date(timestamp); + return [date.getHours(), date.getMinutes(), date.getSeconds()] + .map((part) => String(part).padStart(2, '0')) + .join(':'); +} + +function stateLabel(value) { return text(`state_${value || 'recovery_required'}`); } +function phaseLabel(value) { return text(`phase_${value || 'unknown'}`); } +function statusLabel(value) { return text(`status_${value || 'unknown'}`); } +function scopeLabel(value) { return text(`scope_${value}`); } + +function isWorkspacePreparationFailure(task) { + return Boolean(task && task.error && !task.workspacePath && !task.goalId); +} + +function isResolvedUpstream(task) { + const summary = String(task && task.lastAgentSummary || ''); + return /covered[-_ ]?upstream.{0,80}no[-_ ]?follow[-_ ]?up/is.test(summary) + || /原始故障路径.{0,40}(?:消失|移除).{0,120}(?:不开\s*PR|无需.{0,20}修复)/is.test(summary); +} + +function taskStateLabel(task) { + if (isResolvedUpstream(task)) return stateLabel('resolved_upstream'); + return isWorkspacePreparationFailure(task) ? stateLabel('failed') : stateLabel(task && task.state); +} + +function pendingActionFor(task) { + return task && task.taskId ? state.taskActionPending.get(task.taskId) : ''; +} + +function taskVisualState(task) { + const pending = pendingActionFor(task); + if (pending === 'pause' || pending === 'abort') return 'cancelling'; + if (isResolvedUpstream(task)) return 'completed'; + return isWorkspacePreparationFailure(task) + ? 'failed' + : ((task && task.state) || 'recovery_required'); +} + +function taskStateDisplayLabel(task) { + const pending = pendingActionFor(task); + if (pending === 'pause' || pending === 'abort') return text('pausePending'); + if (pending === 'resume' || pending === 'restore') return text('resumePending'); + if (pending === 'archive') return text('archivePending'); + if (pending) return text('actionPending'); + return taskStateLabel(task); +} + +function taskPhaseLabel(task) { + return isWorkspacePreparationFailure(task) + ? text('workspacePreparationFailed') + : phaseLabel(task && task.phase); +} + +/// The LoopX frontier-todo projection marks the PR-lifecycle monitoring +/// phase. It is display-only; the LoopX registry stays authoritative. +/// Mirrors the Rust classifier `is_loopx_monitor_action` (policy.rs): the +/// `_monitor` suffix family plus the `issue_fix_track_*` merge-readiness +/// trackers. Keep both sides in sync. +function isMonitorTodo(task) { + const todo = task && task.currentTodo; + if (!todo) return false; + if (String(todo.taskClass || '') === 'continuous_monitor') return true; + const kind = String(todo.actionKind || ''); + return /_monitor$/.test(kind) || kind.startsWith('issue_fix_track_'); +} + +function monitorNextCheckLabel(task) { + const raw = task && task.currentTodo && task.currentTodo.nextDueAt; + const value = String(raw || '').trim(); + if (!value) return ''; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value.slice(0, 40); + const pad = (part) => String(part).padStart(2, '0'); + return `${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`; +} + +function monitorWaitDetail(task) { + const detail = text('monitor_waiting_detail'); + const due = monitorNextCheckLabel(task); + return due ? `${detail} ${text('monitor_next_check')}: ${due}` : detail; +} + +function compactItemLabel(item) { + if (!item) return '--'; + return `${item.kind === 'pr' ? 'PR' : 'Issue'} #${item.number}`; +} + +function shortId(value) { + const raw = value == null ? '' : String(value); + return raw.length > 14 ? raw.slice(0, 8) : raw; +} + +function showNotice(message, tone = 'neutral') { + if (!message) { + view.notice.hidden = true; + view.notice.textContent = ''; + view.notice.dataset.tone = ''; + return; + } + view.notice.textContent = message; + view.notice.dataset.tone = tone; + view.notice.hidden = false; +} + +/// Async completions (hydrate, reattach, actions) may settle after the host +/// surface went away; every DOM render must be a no-op then. +function canRender() { + return !state.tornDown + && typeof document !== 'undefined' + && Boolean(document.createDocumentFragment); +} + +function errorMessage(error) { + if (error instanceof Error) return error.message; + return String(error || 'Unknown error'); +} + +function readStoredModelSelection() { + try { + return window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY) || ''; + } catch (_error) { + return ''; + } +} + +function writeStoredModelSelection(value) { + try { + window.localStorage.setItem(MODEL_SELECTION_STORAGE_KEY, value || 'auto'); + } catch (_error) { + // Ignore storage failures; the current select value still applies. + } +} + +function renderIntakeHistory() { + if (!view.intakeHistory) return; + const fragment = document.createDocumentFragment(); + state.intakeHistory.forEach((url) => { + const option = document.createElement('option'); + option.value = url; + fragment.append(option); + }); + view.intakeHistory.replaceChildren(fragment); +} + +async function loadIntakeHistory() { + if (!app || !app.storage || typeof app.storage.get !== 'function') return; + try { + const stored = await app.storage.get(INTAKE_HISTORY_STORAGE_KEY); + state.intakeHistory = Array.isArray(stored) + ? stored.filter((value) => typeof value === 'string' && value.trim()).slice(0, MAX_INTAKE_HISTORY) + : []; + renderIntakeHistory(); + } catch (_error) { + state.intakeHistory = []; + } +} + +async function rememberIntake(value) { + const url = String(value || '').trim(); + if (!url) return; + state.intakeHistory = [ + url, + ...state.intakeHistory.filter((entry) => entry.toLowerCase() !== url.toLowerCase()), + ].slice(0, MAX_INTAKE_HISTORY); + renderIntakeHistory(); + if (!app || !app.storage || typeof app.storage.set !== 'function') return; + try { + await app.storage.set(INTAKE_HISTORY_STORAGE_KEY, state.intakeHistory); + } catch (_error) { + // Input history remains available for the current session. + } +} + +function currentModelSelection() { + const stored = readStoredModelSelection(); + const selected = view.modelSelect && view.modelSelect.value ? view.modelSelect.value : ''; + if (selected && (selected !== 'auto' || !stored || stored === 'auto')) return selected; + return stored || selected || 'auto'; +} + +function describeModelOption(model) { + const displayName = String(model.name || model.modelName || model.id || '').trim(); + const modelName = String(model.modelName || '').trim(); + const provider = String(model.provider || '').trim(); + const primary = displayName || modelName || model.id; + const details = []; + if (modelName && modelName !== primary) details.push(modelName); + if (provider && provider !== primary && provider !== modelName) details.push(provider); + return details.length > 0 ? `${primary} (${details.join(' · ')})` : primary; +} + +function setButtonBusy(button, busy) { + button.disabled = busy; + button.classList.toggle('is-spinning', busy); +} + +function requestId() { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return window.crypto.randomUUID(); + } + return `loopx-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +// Surfaces one owner decision through a host system notification. The host +// owns OS-level toasts: the Agent is forbidden from raising them (see the +// host execution context), so this is the only sanctioned notification path. +async function notifyGateSystemDecision(task, gate) { + if (!app || !app.notifications || typeof app.notifications.system !== 'function') return; + if (app.permissions && app.permissions.notifications && app.permissions.notifications.system !== true) return; + const item = task && task.identity && task.identity.item; + const label = issueDisplayTitle(task) || itemLabel(item); + try { + await app.notifications.system( + text('systemNotificationTitle', { label }), + String((gate.event && gate.event.message) || '').slice(0, 140), + ); + } catch (error) { + console.info('[bitfun-loopx] system notification skipped', error); + } +} + +function emitInstallDiagnostic( + phase, + request = state.environmentInstallRequestId, + action = 'install_loopx', +) { + const requestIdValue = request || 'unassigned'; + console.info('[bitfun-loopx] Install interaction phase', { + phase, + action, + requestId: requestIdValue, + }); + window.parent.postMessage({ + type: 'bitfun:diagnostic', + scope: 'loopx-install', + phase, + action, + requestId: requestIdValue, + }, '*'); +} + +function repositoryLabel(repository) { + if (!repository) return '--'; + return `${repository.owner || '?'}/${repository.repository || '?'}`; +} + +function itemKey(item) { + const repository = item && item.repository ? item.repository : {}; + return `${repository.host || ''}/${repository.owner || ''}/${repository.repository || ''}/${item.kind || ''}/${item.number || 0}`; +} + +function itemLabel(item) { + if (!item) return '--'; + const prefix = item.kind === 'pr' ? 'PR' : 'Issue'; + return `${repositoryLabel(item.repository)} ${prefix} #${item.number}`; +} + +function itemUrl(item) { + if (!item || !item.repository) return ''; + const { host, owner, repository } = item.repository; + if (!host || !owner || !repository || !item.number) return ''; + const path = item.kind === 'pr' ? 'pull' : 'issues'; + return `https://${host}/${owner}/${repository}/${path}/${item.number}`; +} + +function identityTitleOf(task) { + const item = task && task.identity && task.identity.item; + const refreshed = (state.itemMetadata.get(itemKey(item)) || {}).title; + if (typeof refreshed === 'string' && refreshed.trim()) return refreshed.trim(); + const title = task && task.identity && task.identity.title; + return typeof title === 'string' ? title.trim() : ''; +} + +function identityDescriptionOf(task) { + const item = task && task.identity && task.identity.item; + const refreshed = (state.itemMetadata.get(itemKey(item)) || {}).description; + if (typeof refreshed === 'string' && refreshed.trim()) return refreshed.trim(); + const description = task && task.identity && task.identity.description; + return typeof description === 'string' ? description.trim() : ''; +} + +function compactHumanTitle(rawTitle, fallback) { + const cleaned = String(rawTitle || '') + .replace(/^\s*[【[]\s*(?:bug|问题)\s*[】\]]\s*/i, '') + .replace(/^\s*\d{1,2}[./-]\d{1,2}日?\s*/, '') + .trim(); + if (!cleaned) return fallback; + return cleaned.length > 88 ? `${cleaned.slice(0, 87)}…` : cleaned; +} + +function issueContext(task) { + const item = task && task.identity && task.identity.item; + const fallback = item ? compactItemLabel(item) : '--'; + const rawTitle = identityTitleOf(task); + return { title: compactHumanTitle(rawTitle, fallback) }; +} + +function issueDisplayTitle(task) { + return issueContext(task).title; +} + +function latestTaskWaitReason(task) { + if (!task || task.state !== 'queued') return ''; + for (let index = state.events.length - 1; index >= 0; index -= 1) { + const event = state.events[index]; + if (event.taskId !== task.taskId || !event.message) continue; + const message = String(event.message); + if (/another task for this repository/i.test(message)) return text('queuedRepoBusy'); + if (/bounded turn/i.test(message)) return text('queuedBoundedWait'); + return message; + } + return text('queuedRepoBusy'); +} + +function taskForId(taskId) { + if (!state.snapshot || !Array.isArray(state.snapshot.tasks)) return null; + return state.snapshot.tasks.find((task) => task.taskId === taskId) || null; +} + +function selectedTask() { + return state.selectedTaskId ? taskForId(state.selectedTaskId) : null; +} + +function runningOutputTask() { + const selected = selectedTask(); + if (selected && selected.state === 'running' && selected.phase === 'agent_running') return selected; + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) + ? state.snapshot.tasks + : []; + return tasks.find((task) => task.state === 'running' && task.phase === 'agent_running') || null; +} + +/// The task whose context fills the issue workspace. An explicit selection +/// always wins; without one the view follows the currently running task, then +/// the first task in execution order (state priority, then queue order), so +/// the pane opens on the issue that will be solved first. +function displayedTask() { + const selected = selectedTask(); + if (selected) return selected; + return runningOutputTask() || firstActionableTask() || sortedTaskList( + ((state.snapshot && state.snapshot.tasks) || []) + .filter((task) => task.state !== 'archived'), + )[0] || sortedTaskList((state.snapshot && state.snapshot.tasks) || [])[0] || null; +} + +function firstActionableTask() { + const actionableStates = [ + 'waiting_for_user', + 'preparing', + 'queued', + 'retry_wait', + 'recovery_required', + 'failed', + ]; + const actionable = ((state.snapshot && state.snapshot.tasks) || []) + .filter((task) => !taskForId(task.taskId) || !isResolvedUpstream(taskForId(task.taskId))) + .filter((task) => actionableStates.includes(task.state)); + if (!actionable.length) return null; + return [...actionable].sort((left, right) => + taskSortPriority(left) - taskSortPriority(right) + || Number(left.createdAt || left.updatedAt || 0) + - Number(right.createdAt || right.updatedAt || 0))[0] || null; +} + +function isFollowingRunningTask() { + return !state.selectedTaskId && Boolean(runningOutputTask()); +} + +function resetTurnOutput(task) { + state.turnOutput.taskId = task ? task.taskId : null; + state.turnOutput.turnId = task ? (task.currentTurnId || null) : null; + state.turnOutput.streamId = null; + state.turnOutput.cursor = 0; + state.turnOutput.events = []; + state.turnOutput.message = ''; + state.turnOutput.status = task ? 'current' : 'not_running'; +} + +function ensureTurnOutputTarget(task) { + const currentTurn = task && task.currentTurnId ? task.currentTurnId : null; + if ( + state.turnOutput.taskId !== (task && task.taskId) + || state.turnOutput.turnId !== currentTurn + ) { + resetTurnOutput(task); + } +} + +function clearTurnOutputTimer() { + if (state.turnOutput.timer) { + clearTimeout(state.turnOutput.timer); + state.turnOutput.timer = null; + } +} + +function scheduleTurnOutputPoll(delay = 1200) { + if (state.tornDown) return; + clearTurnOutputTimer(); + const task = runningOutputTask(); + if (!task || !app || !app.loopx || typeof app.loopx.turnOutputSince !== 'function') return; + state.turnOutput.timer = setTimeout(() => { + state.turnOutput.timer = null; + void refreshTurnOutput(); + }, delay); +} + +function snapshotSupported() { + return state.snapshot && state.snapshot.executionSupport === 'supported'; +} + +function validEvent(event) { + return event + && typeof event === 'object' + && typeof event.streamId === 'string' + && Number.isSafeInteger(event.cursor) + && event.cursor >= 0; +} + +function addEvent(event) { + if (!validEvent(event)) return false; + const key = `${event.streamId}:${event.cursor}`; + if (state.eventKeys.has(key)) return false; + state.eventKeys.add(key); + state.events.push(event); + state.events.sort((left, right) => left.cursor - right.cursor); + while (state.events.length > MAX_EVENTS) { + const removed = state.events.shift(); + state.eventKeys.delete(`${removed.streamId}:${removed.cursor}`); + } + return true; +} + +function replaceStreamEvents(streamId) { + state.events = state.events.filter((event) => event.streamId === streamId); + state.eventKeys = new Set(state.events.map((event) => `${event.streamId}:${event.cursor}`)); +} + +function clearRunUiState() { + state.events = []; + state.eventKeys.clear(); + state.selectedTaskId = null; + state.preview = null; + state.pendingCreate = null; + state.pendingRetry = null; + state.approvalTaskId = null; + state.promptedGateIds.clear(); + state.pendingApprovalPrompt = false; + state.repositoryResumeTarget = null; + state.repositoryResumePending = false; + state.taskActionPending.clear(); + state.itemMetadata.clear(); + state.metadataRequests.clear(); + state.outputHistory = []; + state.outputKeys.clear(); + state.outputCharacters = 0; + clearTurnOutputTimer(); + resetTurnOutput(null); +} + +async function replayEvents(streamId, afterCursor, historical = false) { + let cursor = afterCursor; + let pageCount = 0; + let changed = false; + while (pageCount < 30) { + pageCount += 1; + const page = await app.loopx.eventsSince({ + streamId, + afterCursor: cursor, + limit: 250, + }); + if (!page || page.status === 'snapshot_required' || page.streamId !== streamId) { + return { snapshotRequired: true, changed }; + } + (page.events || []).forEach((event) => { + changed = addEvent(event) || changed; + }); + cursor = Math.max(cursor, Number(page.nextCursor || cursor)); + if (!page.hasMore) break; + } + if (!historical && state.snapshot) { + state.snapshot.cursor = Math.max(Number(state.snapshot.cursor || 0), cursor); + } + return { snapshotRequired: false, changed }; +} + +async function refreshTurnOutput() { + if (state.turnOutput.inFlight) return; + const task = runningOutputTask(); + if (!task) { + resetTurnOutput(null); + renderLogs(); + return; + } + ensureTurnOutputTarget(task); + if (!app || !app.loopx || typeof app.loopx.turnOutputSince !== 'function') { + state.turnOutput.status = 'output_unavailable'; + state.turnOutput.message = text('outputUnavailable'); + renderLogs(); + return; + } + + state.turnOutput.inFlight = true; + try { + const page = await app.loopx.turnOutputSince({ + taskId: task.taskId, + ...(state.turnOutput.turnId ? { turnId: state.turnOutput.turnId } : {}), + ...(state.turnOutput.streamId ? { streamId: state.turnOutput.streamId } : {}), + afterCursor: state.turnOutput.cursor, + limit: 200, + }); + if (!page || page.taskId !== task.taskId) { + state.turnOutput.status = 'output_unavailable'; + state.turnOutput.message = text('outputUnavailable'); + return; + } + if (page.turnId && page.turnId !== state.turnOutput.turnId) { + resetTurnOutput({ ...task, currentTurnId: page.turnId }); + } + if (page.streamId && page.streamId !== state.turnOutput.streamId) { + state.turnOutput.streamId = page.streamId; + state.turnOutput.cursor = 0; + state.turnOutput.events = []; + } + state.turnOutput.status = page.status || 'current'; + state.turnOutput.message = page.message || ''; + (page.events || []).forEach((event) => { + if (!Number.isSafeInteger(event.cursor)) return; + if (state.turnOutput.events.some((existing) => existing.cursor === event.cursor)) return; + state.turnOutput.events.push(event); + const turnId = event.turnId || page.turnId || state.turnOutput.turnId || ''; + const outputKey = `${task.taskId}:${turnId}:${event.cursor}`; + if (!state.outputKeys.has(outputKey)) { + const rawText = event.text == null ? '' : String(event.text); + const boundedText = rawText.length <= MAX_OUTPUT_EVENT_CHARS + ? rawText + : `${rawText.slice(0, MAX_OUTPUT_EVENT_CHARS / 2)}\n...\n${rawText.slice(-MAX_OUTPUT_EVENT_CHARS / 2)}`; + state.outputKeys.add(outputKey); + state.outputHistory.push({ + ...event, + text: boundedText, + taskId: task.taskId, + turnId, + }); + state.outputCharacters += boundedText.length; + } + }); + state.turnOutput.events.sort((left, right) => left.cursor - right.cursor); + while (state.turnOutput.events.length > MAX_TURN_OUTPUT_EVENTS) { + state.turnOutput.events.shift(); + } + while ( + state.outputHistory.length > MAX_OUTPUT_HISTORY_EVENTS + || state.outputCharacters > MAX_OUTPUT_HISTORY_CHARS + ) { + const removed = state.outputHistory.shift(); + state.outputKeys.delete(`${removed.taskId}:${removed.turnId || ''}:${removed.cursor}`); + state.outputCharacters = Math.max(0, state.outputCharacters - String(removed.text || '').length); + } + state.turnOutput.cursor = Math.max( + state.turnOutput.cursor, + Number(page.nextCursor || state.turnOutput.cursor), + ); + if (page.hasMore) scheduleTurnOutputPoll(0); + else scheduleTurnOutputPoll(1200); + } catch (error) { + state.turnOutput.status = 'output_unavailable'; + state.turnOutput.message = error instanceof Error ? error.message : String(error); + scheduleTurnOutputPoll(3000); + } finally { + state.turnOutput.inFlight = false; + renderLogs(); + } +} + +function applySnapshot(snapshot) { + if (!snapshot || typeof snapshot.streamId !== 'string') { + throw new Error('The host returned an invalid LoopX snapshot.'); + } + const previousStreamId = state.snapshot && state.snapshot.streamId; + const previousSidecarStatus = state.snapshot + && state.snapshot.environment + && state.snapshot.environment.core + && state.snapshot.environment.core.sidecar + && state.snapshot.environment.core.sidecar.status; + const streamChanged = previousStreamId && previousStreamId !== snapshot.streamId; + state.snapshot = snapshot; + if (streamChanged) { + clearRunUiState(); + } else { + replaceStreamEvents(snapshot.streamId); + } + if (state.selectedTaskId && !taskForId(state.selectedTaskId)) { + state.selectedTaskId = null; + } + state.connected = true; + state.lastHostSignalAt = Date.now(); + view.connectionLabel.textContent = text('connected'); + view.root.setAttribute('aria-busy', 'false'); + renderAll(); + if (state.environmentInstallObserved) { + const sidecar = snapshot.environment + && snapshot.environment.core + && snapshot.environment.core.sidecar; + if (sidecar && sidecar.status === 'available') { + emitInstallDiagnostic('environment_available'); + state.environmentInstallObserved = false; + state.environmentInstallRequestId = null; + showNotice(text('loopxInstallComplete'), 'success'); + } else if ( + previousSidecarStatus === 'checking' + && sidecar + && sidecar.status === 'unavailable' + ) { + emitInstallDiagnostic('environment_unavailable'); + state.environmentInstallObserved = false; + state.environmentInstallRequestId = null; + showNotice(text('loopxInstallFailed', { + message: sidecar.detail || statusLabel('unavailable'), + }), 'error'); + } + } + if (state.pendingApprovalPrompt) { + syncApprovalAttention(true); + if (currentApprovalAttention()) state.pendingApprovalPrompt = false; + } +} + +async function attachSnapshot(loadHistory = false, resumeDetected = false) { + if (!app || !app.loopx) { + showBridgeUnavailable(); + return; + } + if (resumeDetected) state.pendingResumeSignal = true; + if (state.syncing) { + state.syncRequested = true; + return; + } + state.syncing = true; + if (!view.repositoryActions.hidden) view.resumeRepository.disabled = true; + try { + do { + state.syncRequested = false; + const reportResume = state.pendingResumeSignal; + state.pendingResumeSignal = false; + const knownStreamId = state.snapshot && state.snapshot.streamId; + const afterCursor = state.snapshot && state.snapshot.cursor; + if (!state.connected) view.connectionLabel.textContent = text('connecting'); + const response = await app.loopx.attach({ + ...(knownStreamId ? { knownStreamId } : {}), + ...(Number.isSafeInteger(afterCursor) ? { afterCursor } : {}), + ...(reportResume ? { resumeDetected: true } : {}), + }); + state.lastReattachAt = Date.now(); + applySnapshot(response && response.snapshot); + void loadModelCatalog(); + const snapshot = state.snapshot; + if (loadHistory && state.events.length === 0 && snapshot.cursor > 0) { + const replay = await replayEvents(snapshot.streamId, 0, true); + if (replay.changed) { + renderLogs(); + syncApprovalAttention(true); + } + } + } while (state.syncRequested); + } catch (error) { + state.connected = false; + view.connectionLabel.textContent = text('connectionFailed'); + showNotice(errorMessage(error), 'error'); + } finally { + state.syncing = false; + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) + ? state.snapshot.tasks + : []; + renderRepositoryActions(tasks); + } +} + +async function recoverEventGap(event) { + if (state.gapRecovery) return state.gapRecovery; + state.gapRecovery = (async () => { + try { + const snapshot = state.snapshot; + if (!snapshot || event.streamId !== snapshot.streamId) { + await attachSnapshot(false); + return; + } + const replay = await replayEvents(snapshot.streamId, snapshot.cursor, false); + if (replay.snapshotRequired) { + await attachSnapshot(false); + return; + } + if (event.cursor > state.snapshot.cursor) { + addEvent(event); + state.snapshot.cursor = event.cursor; + } + renderLogs(); + } catch (error) { + showNotice(errorMessage(error), 'error'); + await attachSnapshot(false); + } finally { + state.gapRecovery = null; + } + })(); + return state.gapRecovery; +} + +function onLoopxEvent(payload) { + const event = payload && payload.event ? payload.event : payload; + if (!validEvent(event)) return; + state.lastHostSignalAt = Date.now(); + if (event.kind === 'snapshot_invalidated' && event.cursor === 0) { + state.syncRequested = true; + queueMicrotask(() => void attachSnapshot(false)); + return; + } + if (!state.snapshot || event.streamId !== state.snapshot.streamId) { + void recoverEventGap(event); + return; + } + const cursor = Number(state.snapshot.cursor || 0); + if (event.cursor > cursor + 1) { + void recoverEventGap(event); + return; + } + const changed = addEvent(event); + if (event.kind === 'approval_required') state.pendingApprovalPrompt = true; + state.snapshot.cursor = Math.max(cursor, event.cursor); + if (changed) { + renderLogs(); + if (event.kind === 'approval_required') syncApprovalAttention(true); + } + if (SNAPSHOT_EVENT_KINDS.has(event.kind)) { + state.syncRequested = true; + queueMicrotask(() => void attachSnapshot(false)); + } +} + +function showBridgeUnavailable() { + state.connected = false; + view.root.setAttribute('aria-busy', 'false'); + view.connectionLabel.textContent = text('connectionFailed'); + view.unsupportedReason.textContent = text('bridgeUnavailable'); + view.unsupportedBanner.hidden = false; + view.resolveButton.disabled = true; + view.retryEnvironment.disabled = true; +} + +function renderExecutionSupport() { + const snapshot = state.snapshot; + const supported = snapshotSupported(); + const environmentStatus = snapshot && snapshot.environment && snapshot.environment.status; + const environmentBusyOrBlocked = environmentStatus === 'checking' || environmentStatus === 'blocked'; + view.unsupportedBanner.hidden = !snapshot || supported; + if (snapshot && !supported) { + view.unsupportedReason.textContent = snapshot.unsupportedReason || text('unsupportedDefault'); + } + view.resolveButton.disabled = !supported || environmentBusyOrBlocked || state.resetPending; + view.retryEnvironment.disabled = !supported + || environmentStatus === 'checking' + || state.environmentInstallPending; +} + +function environmentFact(name, label, fact) { + const element = document.createElement('article'); + const status = fact && fact.status ? fact.status : 'unknown'; + element.className = 'environment-fact'; + element.dataset.status = status; + + const title = document.createElement('div'); + title.className = 'environment-fact__title'; + const strong = document.createElement('strong'); + strong.textContent = label; + const value = document.createElement('span'); + value.textContent = statusLabel(status); + const statusActions = document.createElement('div'); + statusActions.className = 'environment-fact__status-actions'; + statusActions.append(value); + title.append(strong, statusActions); + element.append(title); + + const detail = document.createElement('p'); + const version = fact && fact.version ? fact.version : ''; + const description = (fact && (fact.detail || fact.remediation)) || ''; + detail.textContent = [version, description].filter(Boolean).join(' · ') || statusLabel(status); + detail.title = detail.textContent; + element.append(detail); + element.dataset.fact = name; + return element; +} + +function renderEnvironmentRemediation(sidecar) { + const installChecking = Boolean( + sidecar + && sidecar.status === 'checking' + && sidecar.version === '0.5.1' + ); + const installAvailable = Boolean( + sidecar + && sidecar.remediationAction === 'install_loopx' + ); + const installing = state.environmentInstallPending || installChecking; + view.environmentRemediation.hidden = !installAvailable && !installing; + if (view.environmentRemediation.hidden) return; + + view.environmentRemediation.dataset.state = installing ? 'installing' : 'blocked'; + view.environmentRemediationTitle.textContent = text( + installing ? 'loopxInstallingTitle' : 'loopxRepairTitle', + ); + const detail = String(sidecar && sidecar.detail || ''); + const currentVersion = (detail.match(/got loopx\s+([^\s]+)/i) || [])[1] || statusLabel('unavailable'); + view.environmentRemediationDetail.textContent = installing + ? text('loopxInstallingDetail') + : text('loopxRepairDetail', { current: currentVersion }); + view.environmentRemediationProgress.hidden = !installing; + view.installLoopx.hidden = installing; + view.installLoopx.disabled = installing; + view.installLoopxLabel.textContent = text('installLoopx'); +} + +function renderEnvironment() { + const environment = state.snapshot && state.snapshot.environment; + const status = environment && environment.status ? environment.status : 'unknown'; + view.environmentDot.dataset.status = status; + view.environmentStatus.textContent = statusLabel(status); + view.environmentChecked.textContent = environment && environment.checkedAt + ? text('updated', { duration: relativeLabel(environment.checkedAt) }) + : ''; + + const core = environment && environment.core ? environment.core : {}; + const optional = environment && environment.optional ? environment.optional : {}; + renderEnvironmentRemediation(core.sidecar); + view.coreEnvironmentList.replaceChildren( + environmentFact('sidecar', text('sidecar'), core.sidecar), + environmentFact('gitWorktree', text('gitWorktree'), core.gitWorktree), + environmentFact('agentModel', text('agentModel'), core.agentModel), + ); + view.optionalEnvironmentList.replaceChildren( + environmentFact('pythonFallback', text('pythonFallback'), optional.pythonFallback), + environmentFact('githubAuth', text('githubAuth'), optional.githubAuth), + ); +} + +const ERROR_TASK_STATES = new Set(['recovery_required', 'failed']); +const RECOVERABLE_TASK_STATES = new Set(['stopped', ...ERROR_TASK_STATES]); + +function repositoryKey(repository) { + return repository + ? `${repository.host || ''}/${repository.owner || ''}/${repository.repository || ''}` + : ''; +} + +function taskSortPriority(task) { + if (isResolvedUpstream(task)) return 20; + const priorities = { + running: 0, + waiting_for_user: 1, + preparing: 2, + cancelling: 3, + queued: 4, + retry_wait: 5, + failed: 10, + recovery_required: 11, + stopped: 12, + }; + return priorities[task.state] ?? 20; +} + +function sortedTaskList(tasks) { + return [...tasks].sort((left, right) => + taskExecutionRank(left) - taskExecutionRank(right) + || executionTieBreak(left, right)); +} + +/// Rail and default-focus read in ACTUAL execution order: what is running +/// first, then the queue in creation order, then parked tasks awaiting the +/// owner, then finished work (most recent first). +function taskExecutionRank(task) { + if (isResolvedUpstream(task)) return 40; + const rank = { + running: 0, + preparing: 1, + cancelling: 2, + queued: 3, + retry_wait: 4, + waiting_for_user: 5, + recovery_required: 6, + stopped: 7, + failed: 9, + completed: 20, + archived: 21, + }; + return rank[(task && task.state) || ''] ?? 30; +} + +function executionTieBreak(left, right) { + const leftRank = taskExecutionRank(left); + const rightRank = taskExecutionRank(right); + if (leftRank >= 20 || rightRank >= 20) { + return Number(right.updatedAt || 0) - Number(left.updatedAt || 0); + } + return Number(left.createdAt || left.updatedAt || 0) + - Number(right.createdAt || right.updatedAt || 0); +} + +function progressItemLabel(task) { + const item = task && task.identity && task.identity.item; + return issueDisplayTitle(task) || compactItemLabel(item); +} + +function recoverableTasksForRepository(tasks, repository) { + const key = repositoryKey(repository); + return tasks.filter((task) => + RECOVERABLE_TASK_STATES.has(task.state) + && !isResolvedUpstream(task) + && repositoryKey(task.identity && task.identity.item && task.identity.item.repository) === key); +} + +function renderRepositoryActions(tasks) { + const selected = selectedTask(); + const eligibleRepositories = new Map(); + tasks.forEach((task) => { + if (!RECOVERABLE_TASK_STATES.has(task.state) || isResolvedUpstream(task)) return; + const repository = task.identity && task.identity.item && task.identity.item.repository; + const key = repositoryKey(repository); + if (key && !eligibleRepositories.has(key)) eligibleRepositories.set(key, repository); + }); + const selectedRepository = selected + && selected.identity + && selected.identity.item + && selected.identity.item.repository; + const repository = selectedRepository && eligibleRepositories.has(repositoryKey(selectedRepository)) + ? selectedRepository + : ([...eligibleRepositories.values()][0] || null); + const eligible = repository ? recoverableTasksForRepository(tasks, repository) : []; + state.repositoryResumeTarget = repository && eligible.length > 0 + ? { repository, tasks: eligible } + : null; + view.repositoryActions.hidden = !state.repositoryResumeTarget; + if (!state.repositoryResumeTarget) return; + const modelStatus = state.snapshot + && state.snapshot.environment + && state.snapshot.environment.core + && state.snapshot.environment.core.agentModel + && state.snapshot.environment.core.agentModel.status; + const modelBlocked = modelStatus === 'degraded' || modelStatus === 'unavailable'; + view.resumeRepository.disabled = modelBlocked || state.repositoryResumePending || state.syncing; + view.resumeRepository.textContent = state.repositoryResumePending + ? text('resumingRepository') + : text('resumeRepository', { value: eligible.length }); + view.repositoryActionsMeta.textContent = modelBlocked + ? text('repositoryPausedByModel') + : `${repositoryLabel(repository)} · ${text('repositorySerial')}`; +} + +function taskButton(task) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'task-item'; + if (task.taskId === state.selectedTaskId) button.classList.add('is-selected'); + button.dataset.taskId = task.taskId; + button.setAttribute('aria-pressed', String(task.taskId === state.selectedTaskId)); + + const main = document.createElement('span'); + main.className = 'task-item__main'; + const label = document.createElement('strong'); + const item = task.identity && task.identity.item; + const identityTitle = issueDisplayTitle(task); + label.textContent = identityTitle || compactItemLabel(item); + const meta = document.createElement('small'); + const activity = task.lastOutputAt || task.updatedAt; + meta.textContent = `${repositoryLabel(item && item.repository)} · ${compactItemLabel(item)} · ${relativeLabel(activity)}`; + main.append(label, meta); + if (task.state === 'queued') { + const reason = latestTaskWaitReason(task); + if (reason) main.title = reason; + } + + const taskState = document.createElement('span'); + taskState.className = 'task-item__state'; + const pendingAction = pendingActionFor(task); + const visualState = taskVisualState(task); + button.dataset.state = visualState; + if (pendingAction) button.dataset.pending = pendingAction; + taskState.dataset.status = visualState; + if (pendingAction) { + taskState.classList.add('task-item__hint', 'task-item__hint--pending'); + taskState.textContent = taskStateDisplayLabel(task); + } else { + taskState.classList.add('task-item__hint'); + if (visualState === 'running') taskState.classList.add('task-item__hint--running'); + taskState.textContent = taskStateDisplayLabel(task); + } + taskState.title = taskStateDisplayLabel(task); + button.setAttribute('aria-label', `${label.textContent}, ${taskStateDisplayLabel(task)}`); + const compact = document.createElement('span'); + compact.className = 'task-item__compact'; + compact.textContent = item && item.number ? `#${item.number}` : shortId(task.taskId); + button.append(main, compact, taskState); + button.addEventListener('click', () => selectTask(task.taskId)); + return button; +} + +function renderTasks() { + if (!canRender()) return; + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) ? state.snapshot.tasks : []; + const fragment = document.createDocumentFragment(); + sortedTaskList(tasks).forEach((task) => fragment.append(taskButton(task))); + view.taskItems.replaceChildren(fragment); + view.taskCount.textContent = String(tasks.length); + view.taskEmpty.hidden = tasks.length !== 0; + renderRepositoryActions(tasks); + syncApprovalAttention(false); +} + +function latestGate(taskId) { + const task = taskForId(taskId); + if (task && task.pendingGateId) { + const actionKind = task.pendingGateActionKind || ''; + return { + gateId: task.pendingGateId, + actionKind, + event: { + message: task.pendingGateMessage || text('approvalNeeded'), + details: { + gateId: task.pendingGateId, + actionKind, + }, + }, + }; + } + for (let index = state.events.length - 1; index >= 0; index -= 1) { + const event = state.events[index]; + if (event.taskId !== taskId || event.kind !== 'approval_required') continue; + const details = event.details || {}; + const gateId = details.gateId || details.gate_id || details.id; + if (gateId) { + return { + event, + gateId, + actionKind: details.actionKind || details.action_kind || '', + }; + } + } + return null; +} + +function currentApprovalAttention() { + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) + ? sortedTaskList(state.snapshot.tasks) + : []; + const task = tasks.find((candidate) => candidate.state === 'waiting_for_user') || null; + if (!task) return null; + return { task, gate: latestGate(task.taskId) }; +} + +function gateRawMessage(gate) { + return String(gate && gate.event && gate.event.message || '').trim(); +} + +function stripGatePriorityPrefix(message) { + return message.replace(/^\[[Pp]\d\]\s*/, '').trim(); +} + +function authorityScopeLabels(rawMessage) { + const rawScopes = rawMessage.match(/\[([^\]]+)\]/)?.[1] || ''; + if (!rawScopes) return ''; + const zh = localeId() === 'zh-CN'; + const scopeNames = zh + ? { + write: '写入仓库', + publish: '发布 PR / 公开内容', + external_review_request: '邀请评审', + merge: '合并代码', + } + : { + write: 'repository write', + publish: 'publish (PR / public content)', + external_review_request: 'review requests', + merge: 'merge', + }; + const labels = rawScopes + .split(/[,,]/) + .map((scope) => scopeNames[scope.trim().toLowerCase()] || scope.trim()) + .filter(Boolean); + return labels.join(zh ? '、' : ', '); +} + +function approvalPresentation(task, gate) { + const rawMessage = gateRawMessage(gate); + const body = stripGatePriorityPrefix(rawMessage); + const actionKind = String(gate && gate.actionKind || '').toLowerCase(); + + // 复用既有 PR 的合并门:用户关心的是「不重复实现、复用哪个 PR、之后是否继续跟进」。 + const reuseMerge = actionKind.includes('merge') + || actionKind.includes('reuse') + || /merge\s+PR\s+#(\d+)/i.test(body) + || /reuse[_\s-]*(?:existing[_\s-]*)?pr/i.test(body); + if (reuseMerge) { + const prNumber = body.match(/PR\s+#(\d+)/i)?.[1] || ''; + const pr = prNumber ? `PR #${prNumber}` : text('gateReuseMergeFallbackPr'); + const prTitle = body.match(/merge\s+PR\s+#\d+\s*\(([^)]+)\)/i)?.[1] || ''; + return { + kind: 'reuse_merge', + title: text('gateReuseMergeTitle'), + summary: prTitle + ? text('gateReuseMergeSummaryWithPr', { pr, title: prTitle }) + : text('gateReuseMergeSummary'), + rawMessage: body, + approveEffect: text('gateReuseMergeApproveEffect', { pr }), + rejectEffect: text('gateReuseMergeRejectEffect', { pr }), + recommendation: text('gateReuseMergeRecommendation'), + approveLabel: text('gateReuseMergeApprove'), + rejectLabel: text('gateReuseMergeReject'), + }; + } + + const publishPullRequest = actionKind.includes('publish') + || actionKind.includes('pull_request') + || /\bpr bundle\b|(?:publish|push|creat(?:e|ing|ion)).{0,100}(?:pull request|\bpr\b)/i.test(body); + if (publishPullRequest) { + const branch = body.match(/\bbranch\s+([^,\s)]+)/i)?.[1] || ''; + const commit = body.match(/\bcommit\s+([0-9a-f]{7,40})\b/i)?.[1] || ''; + const messageRepository = body.match(/\bto\s+([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?=;|[\s.,]|$)/i)?.[1] || ''; + const item = task && task.identity && task.identity.item; + const repository = messageRepository || repositoryLabel(item && item.repository) || '--'; + const evidence = taskProgressEvidence(task); + const validated = evidence.validated || evidence.settled || /\b(?:validated|verified)\b/i.test(body); + return { + kind: 'publish', + title: text('publishApprovalTitle'), + summary: branch && commit + ? text('publishApprovalSummary', { branch, commit, repository }) + : text('publishApprovalSummaryGeneric', { repository }), + rawMessage, + approveEffect: text('publishApprovalApproveEffect'), + rejectEffect: text('publishApprovalRejectEffect'), + recommendation: text(validated ? 'publishApprovalRecommendationReady' : 'publishApprovalRecommendationReview'), + approveLabel: text('publishApprovalApprove'), + rejectLabel: text('publishApprovalReject'), + }; + } + + // LoopX issue-fix 契约中的已知 gate 类型:面向人给出中文说明, + // 原始待办文本(英文、技术性)折叠进「原始请求」而不是当作正文。 + const gatedRead = actionKind.includes('body_or_comment_read') + || actionKind.includes('gated_read') + || /gated read|approve a gated read/i.test(body); + if (gatedRead) { + return { + kind: 'gated_read', + title: text('gateGatedReadTitle'), + summary: text('gateGatedReadSummary'), + rawMessage: body, + approveEffect: text('gateGatedReadApproveEffect'), + rejectEffect: text('gateGatedReadRejectEffect'), + recommendation: '', + approveLabel: text('gateGatedReadApprove'), + rejectLabel: text('gateGatedReadReject'), + }; + } + + if (actionKind.includes('clarify') || actionKind.includes('semantic_ambiguity')) { + return { + kind: 'clarify', + title: text('gateClarifyTitle'), + summary: text('gateClarifySummary'), + rawMessage: body, + approveEffect: text('gateClarifyApproveEffect'), + rejectEffect: text('gateClarifyRejectEffect'), + recommendation: '', + approveLabel: text('approve'), + rejectLabel: text('reject'), + }; + } + + if (actionKind.includes('authority') || actionKind.includes('grant_')) { + const scopes = authorityScopeLabels(body); + return { + kind: 'grant_authority', + title: text('gateGrantAuthorityTitle'), + summary: scopes + ? `${text('gateGrantAuthoritySummary')} ${text('gateGrantAuthorityScopes', { scopes })}` + : text('gateGrantAuthoritySummary'), + rawMessage: body, + approveEffect: text('gateGrantAuthorityApproveEffect'), + rejectEffect: text('gateGrantAuthorityRejectEffect'), + recommendation: '', + approveLabel: text('approve'), + rejectLabel: text('reject'), + }; + } + + if (actionKind.includes('draft') || actionKind.includes('ready_for_review')) { + return { + kind: 'draft_ready', + title: text('gateDraftReadyTitle'), + summary: text('gateDraftReadySummary'), + rawMessage: body, + approveEffect: text('gateDraftReadyApproveEffect'), + rejectEffect: text('gateDraftReadyRejectEffect'), + recommendation: '', + approveLabel: text('approve'), + rejectLabel: text('reject'), + }; + } + + return { + kind: 'generic', + title: text('genericApprovalTitle'), + summary: text('genericApprovalSummary'), + rawMessage: body, + approveEffect: text('genericApprovalApproveEffect'), + rejectEffect: text('genericApprovalRejectEffect'), + recommendation: text('genericApprovalRecommendation'), + approveLabel: text('approve'), + rejectLabel: text('reject'), + }; +} + +function syncApprovalAttention(autoOpen = false) { + const attention = currentApprovalAttention(); + state.approvalTaskId = attention ? attention.task.taskId : null; + view.approvalAlert.hidden = !attention; + if (!attention) return; + + const { task, gate } = attention; + const item = task.identity && task.identity.item; + const presentation = approvalPresentation(task, gate); + view.approvalAlertTitle.textContent = `${issueDisplayTitle(task) || itemLabel(item)} · ${text('decisionRequired')}`; + view.approvalAlertOpen.title = presentation.summary; + view.approvalAlertOpen.setAttribute('aria-label', `${presentation.title} ${presentation.summary}`); + view.approvalAlertMessage.textContent = presentation.summary; + + if ( + autoOpen + && gate + && !state.promptedGateIds.has(gate.gateId) + ) { + state.promptedGateIds.add(gate.gateId); + void notifyGateSystemDecision(task, gate); + const selected = selectedTask(); + if (!selected || selected.state !== 'waiting_for_user') selectTask(task.taskId); + } +} + +function makeActionButton(label, action, task, tone) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = tone === 'danger' + ? 'danger-button' + : (tone === 'primary' ? 'primary-button' : 'text-button'); + button.dataset.action = action; + button.textContent = label; + button.addEventListener('click', () => { + void performAction(action, task); + }); + return button; +} + +function makePendingActionButton(task) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'text-button is-pending'; + button.disabled = true; + button.textContent = taskStateDisplayLabel(task); + return button; +} + +function renderTaskActions(task) { + view.taskActions.replaceChildren(); + if (!task || !snapshotSupported()) return; + const fragment = document.createDocumentFragment(); + if (pendingActionFor(task)) { + fragment.append(makePendingActionButton(task)); + view.taskActions.append(fragment); + return; + } + if (isResolvedUpstream(task)) { + return; + } + if (['recovery_required', 'failed', 'stopped'].includes(task.state)) { + fragment.append(makeActionButton(text('resume'), 'resume', task)); + } + if (['stopped', 'completed', 'failed'].includes(task.state)) { + fragment.append(makeActionButton(text('archive'), 'archive', task)); + } + if (task.state === 'archived') { + fragment.append(makeActionButton(text('restore'), 'restore', task)); + } + view.taskActions.append(fragment); +} + +function safeMarkdownUrl(rawUrl, baseUrl) { + try { + const url = new URL(rawUrl, baseUrl || undefined); + return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : ''; + } catch (_error) { + return ''; + } +} + +function appendInlineMarkdown(parent, source, baseUrl) { + const pattern = /(`[^`\n]+`|\[[^\]\n]+\]\([^\s)]+\)|\*\*[^*\n]+\*\*|__[^_\n]+__)/g; + let cursor = 0; + for (const match of source.matchAll(pattern)) { + if (match.index > cursor) parent.append(document.createTextNode(source.slice(cursor, match.index))); + const token = match[0]; + if (token.startsWith('`')) { + const code = document.createElement('code'); + code.textContent = token.slice(1, -1); + parent.append(code); + } else if (token.startsWith('[')) { + const parts = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + const href = parts ? safeMarkdownUrl(parts[2], baseUrl) : ''; + if (parts && href) { + const link = document.createElement('a'); + link.href = href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.textContent = parts[1]; + parent.append(link); + } else { + parent.append(document.createTextNode(token)); + } + } else { + const strong = document.createElement('strong'); + strong.textContent = token.slice(2, -2); + parent.append(strong); + } + cursor = match.index + token.length; + } + if (cursor < source.length) parent.append(document.createTextNode(source.slice(cursor))); +} + +function renderMarkdown(target, source, baseUrl) { + const fragment = document.createDocumentFragment(); + const lines = String(source || '').replace(/\r\n?/g, '\n').split('\n'); + let list = null; + let code = null; + let paragraph = null; + const closeParagraph = () => { paragraph = null; }; + const closeList = () => { list = null; }; + lines.forEach((line) => { + if (/^```/.test(line)) { + closeParagraph(); + closeList(); + if (code) { + code = null; + } else { + const pre = document.createElement('pre'); + code = document.createElement('code'); + pre.append(code); + fragment.append(pre); + } + return; + } + if (code) { + code.append(document.createTextNode(`${code.textContent ? '\n' : ''}${line}`)); + return; + } + if (!line.trim()) { + closeParagraph(); + closeList(); + return; + } + const heading = line.match(/^(#{1,4})\s+(.+)$/); + if (heading) { + closeParagraph(); + closeList(); + const element = document.createElement(`h${Math.min(heading[1].length + 2, 6)}`); + appendInlineMarkdown(element, heading[2], baseUrl); + fragment.append(element); + return; + } + const listItem = line.match(/^\s*(?:[-*+] |\d+\. )(.+)$/); + if (listItem) { + closeParagraph(); + if (!list) { + list = document.createElement(/^\s*\d+\./.test(line) ? 'ol' : 'ul'); + fragment.append(list); + } + const item = document.createElement('li'); + appendInlineMarkdown(item, listItem[1], baseUrl); + list.append(item); + return; + } + closeList(); + const quote = line.match(/^>\s?(.*)$/); + if (quote) { + closeParagraph(); + const element = document.createElement('blockquote'); + appendInlineMarkdown(element, quote[1], baseUrl); + fragment.append(element); + return; + } + if (!paragraph) { + paragraph = document.createElement('p'); + fragment.append(paragraph); + } else { + paragraph.append(document.createElement('br')); + } + appendInlineMarkdown(paragraph, line, baseUrl); + }); + target.replaceChildren(fragment); +} + +function progressTaskEvents(task) { + return state.events.filter((event) => ( + event.taskId === task.taskId + && (event.generation == null || Number(event.generation) === Number(task.generation)) + )); +} + +function compactArtifactPath(value) { + const parts = String(value || '').split(/[\\/]/).filter(Boolean); + return parts.slice(-3).join('/'); +} + +function isProductArtifact(value) { + const normalized = String(value || '').replace(/\\/g, '/').toLowerCase(); + if (!normalized) return false; + return !normalized.startsWith('.loopx/') + && !normalized.includes('/.loopx/') + && !normalized.startsWith('.codex/') + && !normalized.includes('/.codex/') + && !normalized.startsWith('.bitfun/') + && !normalized.includes('/.bitfun/') + && !normalized.includes('/appdata/local/temp/'); +} + +function taskProgressEvidence(task) { + const events = progressTaskEvents(task); + const started = events.filter((event) => event.details && event.details.activity === 'started'); + const countTools = (names) => started.filter((event) => names.has(event.toolName)).length; + const reads = countTools(new Set(['Read', 'LS'])); + const searches = countTools(new Set(['Grep'])); + const web = countTools(new Set(['WebFetch', 'WebSearch'])); + const changes = started.filter((event) => ( + ['Write', 'Edit', 'ApplyPatch'].includes(event.toolName) + && isProductArtifact(event.details && event.details.summary) + )); + const commands = countTools(new Set(['ExecCommand'])); + const failures = events.filter((event) => event.details && event.details.activity === 'failed').length; + const artifacts = [...new Set(changes + .map((event) => compactArtifactPath(event.details && event.details.summary)) + .filter(Boolean))].slice(-3); + const validated = events.some((event) => event.phase === 'validating_progress'); + const settled = Boolean(task.settlement && task.settlement.receiptId); + return { + reads, + searches, + web, + analysisCount: reads + searches + web, + changes: changes.length, + commands, + failures, + artifacts, + validated, + settled, + }; +} + +function currentProgressHeading(task, evidence) { + if (isResolvedUpstream(task)) return text('progressResolvedUpstream'); + if (task.state === 'completed') return text('progressCompleted'); + if (task.state === 'waiting_for_user') return text('progressWaiting'); + if (task.state === 'recovery_required' || task.state === 'failed') return text('progressRecovery'); + if (task.phase === 'preparing_workspace') return text('progressPreparing'); + if (task.state === 'queued' && isMonitorTodo(task)) return text('monitor_phase_queued'); + if (task.state === 'queued' || task.phase === 'queued') return text('progressQueued'); + if (task.phase === 'validating_progress') return text('progressValidating'); + if (task.phase === 'settling_turn') return text('progressSettling'); + if (task.phase === 'agent_running') { + return text(evidence.changes > 0 ? 'progressImplementing' : 'progressAnalyzing'); + } + return text('progressIdle'); +} + +function currentProgressDetail(task, events) { + if (isResolvedUpstream(task)) return text('progressResolvedUpstreamDetail'); + if (task.state === 'queued') { + return isMonitorTodo(task) ? monitorWaitDetail(task) : latestTaskWaitReason(task); + } + if (task.currentTool) { + const activity = [...events].reverse().find((event) => ( + event.toolName === task.currentTool + && event.details + && event.details.activity === 'started' + )); + const summary = activity && activity.details && activity.details.summary; + return activitySummary(task.currentTool, summary); + } + if (task.state === 'recovery_required' || task.state === 'failed') { + return task.error ? String(task.error) : taskPhaseLabel(task); + } + if (task.workspacePath && task.phase === 'creating_goal') { + return compactArtifactPath(task.workspacePath); + } + return taskPhaseLabel(task); +} + +function activitySummary(toolName, rawSummary) { + const summary = String(rawSummary || ''); + if (toolName !== 'ExecCommand') { + const path = compactArtifactPath(summary); + return path ? `${toolLabel(toolName)} · ${path}` : toolLabel(toolName); + } + if (/yarn\s+(?:workspace\s+\S+\s+)?install|pnpm\s+install|npm\s+(?:ci|install)/i.test(summary)) { + return text('activityInstallingDependencies'); + } + if (/dist:win|electron-builder|package-win|makensis|nsis/i.test(summary)) { + return text('activityBuildingInstaller'); + } + if (/smoke-windows-installer-upgrade|installer-upgrade|overwrite-marker|repro-overwrite/i.test(summary)) { + return text('activityTestingUpgrade'); + } + if (/Start-Sleep|Wait-Process|Get-Process|Get-CimInstance/i.test(summary)) { + return text('activityWaitingProcess'); + } + if (/loopx(?:\.exe)?[^\n]*(?:refresh-state|todo|heartbeat-prompt|quota)/i.test(summary)) { + return text('activitySyncingProgress'); + } + if (/\bgit\b/i.test(summary)) return text('activityCheckingRepository'); + return text('activityRunningCommand'); +} + +function renderIssueApproval(task) { + const gate = task && task.state === 'waiting_for_user' ? latestGate(task.taskId) : null; + const gateJustArrived = gate && view.issueApprovalPanel.hidden; + view.issueApprovalPanel.hidden = !gate; + if (gateJustArrived && view.issueDetail) { + // 新到的 owner 决策自己钉在详情列顶部:把滚动位置带回顶部, + // 保证审批卡片完整可见(sticky 定位已保证后续滚动时不被淹没)。 + view.issueDetail.scrollTop = 0; + } + if (!gate) return; + const presentation = approvalPresentation(task, gate); + view.issueApprovalKind.textContent = presentation.kind === 'publish' + ? text('gateKindPublish') + : text('gateKindDecision'); + view.issueApprovalTitle.textContent = presentation.title; + view.issueApprovalMessage.textContent = presentation.summary; + const rawBody = String(presentation.rawMessage || '').trim(); + const rawDiffers = rawBody && rawBody !== presentation.summary; + view.issueApprovalRaw.hidden = !rawDiffers; + view.issueApprovalRawText.textContent = rawDiffers ? rawBody : ''; + view.issueApprovalApproveEffect.textContent = presentation.approveEffect; + view.issueApprovalRejectEffect.textContent = presentation.rejectEffect; + view.issueApprovalRecommendation.textContent = presentation.recommendation; + const pending = Boolean(pendingActionFor(task)); + view.issueApprovalApprove.textContent = pending ? text('approvalSubmittingShort') : presentation.approveLabel; + view.issueApprovalReject.textContent = presentation.rejectLabel; + view.issueApprovalApprove.disabled = pending; + view.issueApprovalReject.disabled = pending; + view.issueApprovalNote.disabled = pending; +} + +function renderIssueStatus(task) { + const card = view.issueDecisionCard; + if (!card) return; + const waiting = Boolean(task) && task.state === 'waiting_for_user'; + const recovery = Boolean(task) && task.state === 'recovery_required'; + const show = Boolean(task) + && !isResolvedUpstream(task) + && (waiting || recovery); + card.hidden = !show; + if (!show) { + card.replaceChildren(); + return; + } + card.replaceChildren(); + const planExhausted = recovery && task.recoveryReason === 'plan_exhausted'; + const heading = document.createElement('strong'); + heading.textContent = text(waiting + ? 'decisionCardTitle' + : (planExhausted ? 'decisionCardTitlePlanExhausted' : 'decisionCardTitleRecovery')); + const body = document.createElement('p'); + body.className = 'issue-decision-card__message'; + const recoveryHint = !waiting + ? (String(task.pendingGateMessage || '').trim() || text(planExhausted ? 'decisionCardPlanExhaustedHint' : 'decisionCardRecoveryHint')) + : text('decisionCardGateHint'); + body.textContent = recoveryHint; + card.append(heading, body); + const reasonKey = !waiting && task.recoveryReason + ? `recoveryReason${String(task.recoveryReason).split('_').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')}` + : null; + if (reasonKey) { + const reason = document.createElement('p'); + reason.className = 'issue-decision-card__message'; + const table = COPY[localeId()] || COPY['en-US']; + const reasonText = table[reasonKey] || COPY['en-US'][reasonKey] || ''; + if (reasonText) reason.textContent = reasonText; + else reason.hidden = true; + card.append(reason); + } + const actions = document.createElement('div'); + actions.className = 'issue-decision-card__actions'; + if (recovery) { + actions.append(makeActionButton(text('decisionResume'), 'resume', task, 'primary')); + } + card.append(actions); +} + +function summaryEnumLabel(prefix, value) { + const key = `${prefix}${String(value || '').split('_').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')}`; + const table = COPY[localeId()] || COPY['en-US']; + return table[key] || fallbackCopy(key); +} + +function fallbackCopy(key) { + return COPY['en-US'][key] || ''; +} + +function stripSummaryBlock(raw) { + return String(raw || '') + .replace(/```loopx_summary_v1[\s\S]*?```/g, '') + .trim(); +} + +function renderStructuredBrief(container, s, raw, task) { + container.replaceChildren(); + const badges = document.createElement('div'); + badges.className = 'summary-badges'; + const verdict = document.createElement('span'); + verdict.className = 'summary-badge'; + verdict.textContent = summaryEnumLabel('summaryVerdict', s.issue_verdict) || s.issue_verdict; + badges.append(verdict); + if (s.issue_verdict === 'already_fixed_upstream' && s.fixed_by) { + const fixed = document.createElement('a'); + fixed.className = 'summary-badge summary-badge--link'; + fixed.href = s.fixed_by; + fixed.target = '_blank'; + fixed.rel = 'noreferrer'; + fixed.textContent = s.fixed_by; + badges.append(fixed); + } + if (s.reproduction && s.reproduction !== 'not_applicable') { + // Only reproduced / not-reproduced carry signal for the reader; + // `not_applicable` (monitoring or analysis segments) adds no information + // and rendered as a badge it reads as duplicated noise. + const reproduction = document.createElement('span'); + reproduction.className = 'summary-badge'; + reproduction.textContent = summaryEnumLabel('summaryReproduction', s.reproduction) || s.reproduction; + badges.append(reproduction); + } + container.append(badges); + + if (s.issue_verdict) { + const badgeNote = document.createElement('p'); + badgeNote.className = 'summary-pending'; + badgeNote.textContent = text('summaryBadgeNote'); + container.append(badgeNote); + } + + if (task && task.state === 'waiting_for_user') { + const pending = document.createElement('p'); + pending.className = 'summary-pending'; + pending.textContent = text('summaryPendingGate'); + container.append(pending); + } + + if (Array.isArray(s.completed) && s.completed.length) { + const section = document.createElement('div'); + section.className = 'summary-section'; + const title = document.createElement('strong'); + const kind = s.segment_kind ? `(${summaryEnumLabel('summarySegment', s.segment_kind)})` : ''; + title.textContent = `📋 ${text('summaryCompletedTitle')}${kind}`; + section.append(title); + const list = document.createElement('ul'); + s.completed.forEach((item) => { + const li = document.createElement('li'); + li.textContent = String(item); + list.append(li); + }); + section.append(list); + container.append(section); + } + + const decision = s.decision && typeof s.decision === 'object' ? s.decision : null; + if (decision && decision.route) { + const section = document.createElement('div'); + section.className = 'summary-section'; + const title = document.createElement('strong'); + title.textContent = `📌 ${text('summaryDecisionTitle')}`; + section.append(title); + const route = document.createElement('p'); + route.textContent = decision.route + (decision.reason ? `(${decision.reason})` : ''); + section.append(route); + if (Array.isArray(decision.rejected) && decision.rejected.length) { + const rejected = document.createElement('details'); + rejected.className = 'summary-rejected'; + const summaryLine = document.createElement('summary'); + summaryLine.textContent = text('summaryRejectedTitle'); + rejected.append(summaryLine); + decision.rejected.forEach((item) => { + const line = document.createElement('div'); + line.textContent = `✗ ${item.route}${item.why ? `:${item.why}` : ''}`; + rejected.append(line); + }); + section.append(rejected); + } + container.append(section); + } + + if (s.next_step) { + const section = document.createElement('div'); + section.className = 'summary-section'; + const title = document.createElement('strong'); + title.textContent = `⏭️ ${text('summaryNextStep')}`; + section.append(title); + const body = document.createElement('p'); + body.textContent = String(s.next_step); + section.append(body); + container.append(section); + } + + if (Array.isArray(s.blockers) && s.blockers.length) { + const section = document.createElement('div'); + section.className = 'summary-section'; + const title = document.createElement('strong'); + title.textContent = `⚠️ ${text('summaryBlockers')}`; + section.append(title); + const list = document.createElement('ul'); + s.blockers.forEach((item) => { + const li = document.createElement('li'); + li.textContent = String(item); + list.append(li); + }); + section.append(list); + container.append(section); + } + + const receiptSource = stripSummaryBlock(raw); + if (receiptSource) { + const receipts = document.createElement('details'); + receipts.className = 'summary-receipts'; + const summaryLine = document.createElement('summary'); + summaryLine.textContent = text('summaryTechReceipts'); + receipts.append(summaryLine); + const body = document.createElement('pre'); + body.className = 'summary-receipts__body'; + body.textContent = receiptSource; + receipts.append(body); + container.append(receipts); + } +} + +function renderIssueBrief(task) { + const summary = String(task.lastAgentSummary || '').trim(); + const structured = task.structuredSummary && typeof task.structuredSummary === 'object' + ? task.structuredSummary + : null; + if (structured) { + renderStructuredBrief(view.issueSummary, structured, summary, task); + view.issueSummaryMeta.textContent = task.lastAgentSummaryAt + ? text('outcomeUpdated', { duration: relativeLabel(task.lastAgentSummaryAt) }) + : ''; + } else if (summary) { + renderMarkdown(view.issueSummary, summary, itemUrl(task.identity && task.identity.item)); + view.issueSummaryMeta.textContent = task.lastAgentSummaryAt + ? text('outcomeUpdated', { duration: relativeLabel(task.lastAgentSummaryAt) }) + : ''; + } else { + view.issueSummary.replaceChildren(); + view.issueSummary.append(text('summaryEmpty')); + view.issueSummaryMeta.textContent = ''; + } + + const evidence = taskProgressEvidence(task); + const facts = []; + if (task.workspacePath) { + facts.push({ label: text('factsWorkspace'), value: compactArtifactPath(task.workspacePath) }); + } + if (task.goalId) { + facts.push({ label: text('factsTurn'), value: shortId(task.goalId) }); + } + if (task.settlement && task.settlement.receiptId) { + facts.push({ label: text('factsReceipt'), value: shortId(task.settlement.receiptId) }); + } + if (task.modelId && task.modelId !== 'auto') { + facts.push({ label: text('factsModel'), value: task.modelId }); + } + if (isMonitorTodo(task) && task.currentTodo && task.currentTodo.nextDueAt) { + facts.push({ + label: text('monitor_chip'), + value: `${text('monitor_next_check')} ${monitorNextCheckLabel(task)}`, + }); + } + if (evidence.artifacts.length > 0) { + facts.push({ + label: text('factsArtifacts'), + value: evidence.artifacts.join(' · '), + }); + } + if (facts.length === 0) { + facts.push({ label: text('factsArtifacts'), value: text('factsArtifactNone') }); + } + const factFragment = document.createDocumentFragment(); + facts.slice(0, 6).forEach((fact) => { + const chip = document.createElement('li'); + chip.className = 'issue-facts__chip'; + const label = document.createElement('span'); + label.className = 'issue-facts__label'; + label.textContent = fact.label; + const value = document.createElement('strong'); + value.textContent = fact.value; + value.title = fact.value; + chip.append(label, value); + factFragment.append(chip); + }); + view.issueFacts.replaceChildren(factFragment); + + const error = String(task.error || '').trim(); + view.issueError.hidden = !error; + view.issueError.textContent = error ? `${text('errorTitle')}:${error}` : ''; +} + +function renderFollowBanner(task) { + const following = isFollowingRunningTask(); + view.followBanner.hidden = !following || !task; + if (!following || !task) return; + const item = task.identity && task.identity.item; + view.followBannerText.textContent = text('followBanner', { + item: compactItemLabel(item), + state: taskStateDisplayLabel(task), + }); + view.followBanner.title = text('followBannerHint'); +} + +function renderIssueView() { + if (!canRender()) return; + const task = displayedTask(); + view.issueView.hidden = !task; + view.issueEmpty.hidden = Boolean(task); + renderFollowBanner(task); + if (!task) { + renderTaskActions(null); + return; + } + + const visualState = taskVisualState(task); + const item = task.identity && task.identity.item; + const url = itemUrl(item); + const itemLabelText = itemLabel(item); + view.issueTitle.textContent = issueDisplayTitle(task) || itemLabelText; + view.issueStatePill.hidden = false; + view.issueStatePill.dataset.state = visualState; + view.issueStatePill.textContent = taskStateDisplayLabel(task); + view.issueLink.hidden = !url; + view.issueLink.textContent = itemLabelText; + if (url) { + view.issueLink.href = url; + view.issueLink.setAttribute('aria-label', `${text('openInGithub')}: ${itemLabelText}`); + } else { + view.issueLink.removeAttribute('href'); + view.issueLink.removeAttribute('aria-label'); + } + view.issueUpdated.textContent = task.updatedAt + ? text('updated', { duration: relativeLabel(task.updatedAt) }) + : ''; + view.issueNumber.textContent = item && item.number + ? `${item.kind === 'pr' ? 'PR' : 'Issue'} #${item.number}` + : ''; + renderIssueApproval(task); + renderIssueStatus(task); + renderIssueBrief(task); + + const description = identityDescriptionOf(task); + const metadataKey = itemKey(item); + const loadingDescription = state.metadataRequests.has(metadataKey); + const descriptionUnavailable = (state.itemMetadata.get(metadataKey) || {}).unavailable === true; + view.issueDescriptionPanel.hidden = false; + renderMarkdown( + view.issueDescription, + description || (descriptionUnavailable + ? text('issueDescriptionUnavailable') + : text('loadingIssueDescription')), + url, + ); + renderTaskActions(task); +} + +function eventSourceLabel(source) { + const keys = { + controller: 'sourceScheduler', + sidecar: 'sourceLoopx', + agent: 'sourceAgent', + git: 'sourceGit', + github: 'sourceGithub', + system: 'sourceSystem', + }; + return keys[source] ? text(keys[source]) : (source || text('sourceScheduler')); +} + +function toolLabel(toolName) { + const keys = { + ExecCommand: 'toolExecCommand', + Read: 'toolRead', + Grep: 'toolGrep', + LS: 'toolLs', + WebFetch: 'toolWebFetch', + WebSearch: 'toolWebSearch', + Write: 'toolWrite', + Edit: 'toolEdit', + }; + return keys[toolName] ? text(keys[toolName]) : (toolName || text('outputTool')); +} + +function toolStateLabel(stateValue) { + const key = { + queued: 'toolStateQueued', + waiting: 'toolStateWaiting', + started: 'toolStateStarted', + confirmation: 'toolStateConfirmation', + confirmed: 'toolStateConfirmed', + rejected: 'toolStateRejected', + completed: 'toolStateCompleted', + failed: 'toolStateFailed', + cancelled: 'toolStateCancelled', + }[stateValue]; + return key ? text(key) : stateValue; +} + +function eventMessage(event) { + const activity = event.details && event.details.activity; + const key = { + queued: 'toolQueued', + waiting: 'toolWaiting', + started: 'toolStarted', + confirmation: 'toolConfirmation', + confirmed: 'toolConfirmed', + rejected: 'toolRejected', + completed: 'toolCompleted', + failed: 'toolFailed', + cancelled: 'toolCancelled', + }[activity]; + if (key) { + const label = text(key, { tool: toolLabel(event.toolName || event.details.toolName) }); + // Completed/failed projections carry a redacted input summary (command, + // file path, pattern) — show it so tool rows identify what they did. + const summary = event.details && event.details.summary; + return summary ? `${label} · ${summary}` : label; + } + return event.message || event.kind || 'event'; +} + + +function outputKindLabel(kind) { + if (kind === 'thinking') return text('outputThinking'); + if (kind === 'tool') return text('outputTool'); + if (kind === 'model_round_started' || kind === 'model_round_completed') return text('outputModel'); + return text('outputText'); +} + +function appendOutputText(existing, next) { + const value = next == null ? '' : String(next); + if (!value) return existing; + const combined = existing ? `${existing}${value}` : value; + if (combined.length <= MAX_OUTPUT_BLOCK_CHARS) return combined; + return `${combined.slice(0, MAX_OUTPUT_BLOCK_CHARS / 2)}\n...\n${combined.slice(-MAX_OUTPUT_BLOCK_CHARS / 2)}`; +} + +function outputEventFallbackText(event) { + if (event.text) return event.text; + if (event.toolState) return event.toolState; + if (event.kind === 'model_round_started') return 'Model round started'; + if (event.kind === 'model_round_completed') return 'Model round completed'; + return event.kind || text('outputText'); +} + +function canMergeOutputEvent(event) { + return event.kind === 'thinking' || event.kind === 'text'; +} + +function compactTurnOutputBlocks(rawEvents) { + // Drop empty chunks and stray marker chunks (for example a thinking chunk + // whose entire text is the word "thinking") before grouping. + const events = rawEvents.filter((event) => { + if (event.kind !== 'thinking' && event.kind !== 'text') return true; + const value = String(event.text == null ? '' : event.text).trim(); + if (!value) return false; + return !(event.kind === 'thinking' && value.toLowerCase() === 'thinking'); + }); + const blocks = []; + events.forEach((event) => { + const kind = event.kind || 'text'; + const roundId = event.roundId || ''; + const toolName = event.toolName || ''; + const last = blocks[blocks.length - 1]; + const sameToolRun = kind === 'tool' + && last + && last.kind === 'tool' + && last.toolName === toolName + && last.roundId === roundId + && last.taskId === event.taskId + && last.turnId === event.turnId; + if ( + (canMergeOutputEvent(event) + && last + && last.kind === kind + && last.roundId === roundId + && last.taskId === event.taskId + && last.turnId === event.turnId + && !last.isEnd) + || sameToolRun + ) { + last.endCursor = event.cursor; + if (kind === 'tool') { + // Later tool lifecycle events supersede earlier ones: the completed + // summary describes the same invocation better than the started one. + if (event.text) last.text = event.text; + last.toolState = event.toolState || last.toolState; + } else { + last.text = appendOutputText(last.text, event.text); + } + last.isEnd = Boolean(last.isEnd || event.isEnd); + last.eventCount += 1; + return; + } + blocks.push({ + startCursor: event.cursor, + endCursor: event.cursor, + taskId: event.taskId || '', + turnId: event.turnId || '', + kind, + roundId, + toolName, + toolState: event.toolState || '', + text: outputEventFallbackText(event), + isEnd: Boolean(event.isEnd), + eventCount: 1, + }); + }); + // Fold trivial fragments (sentence tails like a lone period) into the + // preceding text block of the same turn so they do not become rows. + const merged = []; + blocks.forEach((block) => { + const previous = merged[merged.length - 1]; + if ( + previous + && block.kind === 'text' + && previous.kind === 'text' + && previous.taskId === block.taskId + && previous.turnId === block.turnId + && (block.text || '').trim().length <= 3 + ) { + previous.endCursor = block.endCursor; + previous.text = appendOutputText(previous.text, block.text); + previous.isEnd = Boolean(previous.isEnd || block.isEnd); + previous.eventCount += block.eventCount; + return; + } + merged.push(block); + }); + return merged; +} + +function cursorRangeLabel(block) { + return block.startCursor === block.endCursor + ? `#${block.startCursor}` + : `#${block.startCursor}-${block.endCursor}`; +} + +function outputBlockDomKey(block) { + return `${block.taskId}:${block.turnId}:${block.kind}:${block.startCursor}`; +} + +function outputBlockDomVersion(block) { + return `${block.endCursor}:${block.eventCount}:${block.toolState}:${String(block.text || '').length}`; +} + +function turnOutputBlockRow(block) { + const row = document.createElement('li'); + row.className = 'log-row turn-output-row'; + row.dataset.kind = block.kind; + row.dataset.level = block.toolState === 'failed' ? 'error' : 'info'; + row.dataset.cursor = String(block.endCursor); + row.dataset.taskId = block.taskId; + row.dataset.blockKey = outputBlockDomKey(block); + row.dataset.blockVersion = outputBlockDomVersion(block); + + const header = document.createElement('div'); + header.className = 'output-block__header'; + + const task = taskForId(block.taskId); + const item = task && task.identity && task.identity.item; + const issue = document.createElement(itemUrl(item) ? 'a' : 'span'); + issue.className = 'output-block__issue'; + issue.textContent = item ? compactItemLabel(item) : text('taskNumber', { value: shortId(block.taskId) }); + if (issue.tagName === 'A') { + issue.href = itemUrl(item); + issue.target = '_blank'; + issue.rel = 'noopener noreferrer'; + issue.title = issueDisplayTitle(task) || itemLabel(item); + } + + const level = document.createElement('span'); + level.className = 'event-level'; + level.dataset.level = block.toolState === 'failed' ? 'error' : 'info'; + level.textContent = outputKindLabel(block.kind); + + const source = document.createElement('span'); + source.className = 'output-block__source'; + source.textContent = block.toolName ? toolLabel(block.toolName) : eventSourceLabel('agent'); + + const cursor = document.createElement('span'); + cursor.className = 'output-block__cursor'; + cursor.textContent = cursorRangeLabel(block); + + header.append(issue, level, source, cursor); + + if (block.roundId) { + const round = document.createElement('span'); + round.className = 'output-block__meta'; + round.textContent = shortId(block.roundId); + round.title = block.roundId; + header.append(round); + } + if (block.toolState) { + const status = document.createElement('span'); + status.className = 'output-block__meta'; + status.textContent = toolStateLabel(block.toolState); + header.append(status); + } + if (block.eventCount > 1) { + const chunks = document.createElement('span'); + chunks.className = 'output-block__meta'; + chunks.textContent = text('outputChunks', { value: block.eventCount }); + header.append(chunks); + } + + if (block.kind === 'thinking') { + const thinkingKey = outputBlockDomKey(block); + const details = document.createElement('details'); + details.className = 'output-block__thinking'; + // Expansion is remembered across re-renders: streaming regrows the block + // and would otherwise collapse it under the reader. + if (state.expandedThinking.has(thinkingKey)) details.open = true; + details.addEventListener('toggle', () => { + if (details.open) state.expandedThinking.add(thinkingKey); + else state.expandedThinking.delete(thinkingKey); + }); + const summary = document.createElement('summary'); + summary.textContent = text('outputThinkingSummary', { value: (block.text || '').length }); + const content = document.createElement('div'); + content.className = 'output-block__message'; + content.textContent = block.text || outputKindLabel(block.kind); + details.append(summary, content); + row.append(header, details); + return row; + } + + const message = document.createElement('div'); + message.className = 'output-block__message'; + message.textContent = block.text || outputKindLabel(block.kind); + + row.append(header, message); + return row; +} + +function timelineMilestoneRow(event) { + const row = document.createElement('li'); + row.className = 'log-row log-row--milestone'; + row.dataset.level = event.level || 'info'; + row.dataset.important = String(Boolean(event.important)); + row.dataset.eventKey = String(event.cursor); + + const time = document.createElement('time'); + time.className = 'log-time'; + time.dateTime = new Date(normalizeTimestamp(event.occurredAt)).toISOString(); + time.textContent = clockLabel(event.occurredAt); + + const source = document.createElement('span'); + source.className = 'log-source'; + source.textContent = eventSourceLabel(event.source); + + const content = document.createElement('div'); + content.className = 'milestone-row__message'; + content.textContent = eventMessage(event); + + row.append(time, source, content); + return row; +} + +function timelineStageCard(task) { + const item = task && task.identity && task.identity.item; + const card = document.createElement('div'); + card.className = 'timeline-stage-card'; + if (!task) { + const message = document.createElement('p'); + message.textContent = text('noLogs'); + card.append(message); + return card; + } + const heading = document.createElement('strong'); + heading.textContent = task && task.state === 'queued' && isMonitorTodo(task) + ? text('monitor_phase_queued') + : taskPhaseLabel(task); + card.append(heading); + const detail = document.createElement('p'); + const taskEvents = progressTaskEvents(task); + if (task.state === 'queued') { + detail.textContent = isMonitorTodo(task) ? monitorWaitDetail(task) : latestTaskWaitReason(task); + } else if (task.phase === 'preparing_workspace') { + const elapsed = task.updatedAt ? relativeLabel(task.updatedAt) : ''; + detail.textContent = elapsed + ? `${text('preparingElapsed', { duration: elapsed })} · ${text('worktreeQuiet', { item: compactItemLabel(item) })}` + : text('worktreeQuiet', { item: compactItemLabel(item) }); + } else if (task.state === 'running' && task.phase === 'agent_running') { + detail.textContent = text('awaitingFirstOutput'); + } else { + detail.textContent = currentProgressDetail(task, taskEvents); + } + card.append(detail); + return card; +} + +function renderTimeline() { + if (!canRender()) return; + const running = runningOutputTask(); + if (running) ensureTurnOutputTarget(running); + const task = displayedTask(); + + view.timelineScope.textContent = task + ? text(state.selectedTaskId ? 'timelineIdleScope' : 'timelineLiveScope', { + item: compactItemLabel(task.identity && task.identity.item), + }) + : ''; + + // Model-output blocks are keyed per turn; each block group is anchored to + // its turn so the merged timeline stays in chronological order. Durable + // milestone events (scheduler/engine heartbeats) are intentionally not + // rendered: the issue summary and decision card cover that information. + const taskBlocks = task + ? compactTurnOutputBlocks(state.outputHistory.filter((event) => event.taskId === task.taskId)) + : []; + const visibleBlocks = taskBlocks.slice(-MAX_RENDERED_OUTPUT_BLOCKS); + const blockGroups = []; + visibleBlocks.forEach((block) => { + const last = blockGroups[blockGroups.length - 1]; + if (last && last.turnId === block.turnId) last.blocks.push(block); + else blockGroups.push({ turnId: block.turnId, blocks: [block] }); + }); + + const rows = []; + if (visibleBlocks.length === 0) { + // No live output captured for this task: fall back to the durable + // tool-activity log so the timeline is not blank for older turns. + const toolEvents = task + ? state.events.filter((event) => ( + event.taskId === task.taskId + && event.kind === 'log' + && (event.generation == null || Number(event.generation) === Number(task.generation)) + )) + : []; + toolEvents.forEach((event) => { + rows.push({ key: `e:${event.cursor}`, kind: 'milestone', event }); + }); + } else { + blockGroups.forEach((group) => { + group.blocks.forEach((block) => rows.push({ key: `b:${outputBlockDomKey(block)}`, kind: 'block', block })); + }); + } + const visibleRows = rows.slice(-MAX_RENDERED_OUTPUT_BLOCKS); + + const existingBlocks = new Map( + [...view.logList.children] + .filter((node) => node.dataset && node.dataset.blockKey) + .map((node) => [node.dataset.blockKey, node]), + ); + const existingEvents = new Map( + [...view.logList.children] + .filter((node) => node.dataset && node.dataset.eventKey) + .map((node) => [node.dataset.eventKey, node]), + ); + const desired = visibleRows.map((row) => { + if (row.kind === 'block') { + const node = existingBlocks.get(row.key); + return node && node.dataset.blockVersion === outputBlockDomVersion(row.block) + ? node + : turnOutputBlockRow(row.block); + } + const node = existingEvents.get(row.key); + return node || timelineMilestoneRow(row.event); + }); + desired.forEach((node, index) => { + const current = view.logList.children[index]; + if (current !== node) view.logList.insertBefore(node, current || null); + }); + const desiredNodes = new Set(desired); + [...view.logList.children].forEach((node) => { + if (!desiredNodes.has(node)) node.remove(); + }); + + const hasRows = visibleRows.length !== 0; + view.logEmpty.hidden = hasRows; + if (!hasRows) { + view.logEmptyText.textContent = state.turnOutput.message || text('noLiveOutput'); + const stageCard = timelineStageCard(task); + view.logEmpty.querySelector('svg').hidden = Boolean(task); + const previousCard = view.logEmpty.querySelector('.timeline-stage-card'); + if (previousCard) previousCard.remove(); + if (task) view.logEmpty.append(stageCard); + } + if (state.followLogs) { + requestAnimationFrame(() => { + view.logScroll.scrollTop = view.logScroll.scrollHeight; + view.newEvents.hidden = true; + }); + } else if (visibleRows.length) { + view.newEvents.hidden = false; + } + if (running && !state.turnOutput.inFlight && !state.turnOutput.timer) { + scheduleTurnOutputPoll(state.turnOutput.events.length ? 1200 : 0); + } +} + +function renderLogs() { + renderTimeline(); +} + +function renderAll() { + if (!canRender()) return; + renderExecutionSupport(); + renderEnvironment(); + renderTasks(); + renderIssueView(); + renderLogs(); +} + +async function hydrateTaskMetadata(taskId) { + const task = taskForId(taskId); + const item = task && task.identity && task.identity.item; + if (!item) return; + const metadataKey = itemKey(item); + if (state.metadataRequests.has(metadataKey) || state.itemMetadata.has(metadataKey)) return; + state.metadataRequests.add(metadataKey); + renderIssueView(); + try { + const response = await app.loopx.resolveIntake({ + input: itemUrl(item), + modelId: task.modelId || 'auto', + }); + const candidates = response && response.preview && Array.isArray(response.preview.candidates) + ? response.preview.candidates + : []; + const candidate = candidates.find((entry) => itemKey(entry.key) === metadataKey); + state.itemMetadata.set(metadataKey, { + title: candidate && candidate.title ? candidate.title : '', + description: candidate && candidate.description ? candidate.description : '', + unavailable: !candidate, + }); + } catch (_error) { + state.itemMetadata.set(metadataKey, { unavailable: true }); + } finally { + state.metadataRequests.delete(metadataKey); + renderTasks(); + renderIssueView(); + } +} + +function selectTask(taskId) { + const changed = state.selectedTaskId !== (taskId || null); + state.selectedTaskId = taskId || null; + if (changed) view.issueApprovalNote.value = ''; + renderTasks(); + renderIssueView(); + renderTimeline(); + if (taskId) void hydrateTaskMetadata(taskId); +} + +function unselectTask() { + selectTask(null); +} + +function focusTaskLogs(taskId) { + state.followLogs = true; + selectTask(taskId || null); + if (!taskId) return; + window.requestAnimationFrame(() => { + view.issueWorkspace.focus({ preventScroll: true }); + view.logScroll.scrollTop = view.logScroll.scrollHeight; + }); +} + +function factValue(value, fallback = '--') { + return value == null || value === '' ? fallback : String(value); +} + +function renderPreview(preview) { + state.preview = preview; + const repository = preview.repository || {}; + view.intakeDialogTitle.textContent = repositoryLabel(repository); + view.previewRepository.textContent = repositoryLabel(repository); + view.previewWorkspace.textContent = preview.workspace && preview.workspace.path + ? preview.workspace.path + : text(`workspace_${(preview.workspace && preview.workspace.disposition) || 'unavailable'}`); + view.previewModel.textContent = factValue(preview.model && preview.model.modelId, view.modelSelect.value); + view.previewImages.textContent = preview.model && preview.model.supportsImages + ? text('supported') + : text('unsupported'); + + const candidates = Array.isArray(preview.candidates) ? preview.candidates : []; + const candidateFragment = document.createDocumentFragment(); + candidates.forEach((candidate) => { + const label = document.createElement('label'); + label.className = 'candidate-item'; + label.dataset.state = candidate.state || 'unknown'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.name = 'candidate'; + input.value = itemKey(candidate.key); + const resolved = candidate.state === 'closed' || candidate.state === 'merged'; + input.disabled = resolved; + input.checked = !resolved && candidate.defaultSelected === true; + input.addEventListener('change', updateCreateButton); + + const copy = document.createElement('span'); + copy.className = 'candidate-copy'; + const title = document.createElement('strong'); + title.textContent = candidate.title || itemLabel(candidate.key); + const meta = document.createElement('small'); + meta.textContent = candidate.fromRepository + ? `${itemLabel(candidate.key)} · ${text('fromRepository')}` + : itemLabel(candidate.key); + copy.append(title, meta); + + const itemState = document.createElement('span'); + itemState.className = 'candidate-state'; + itemState.textContent = resolved ? text('resolvedItem') : text('openItem'); + label.append(input, copy, itemState); + candidateFragment.append(label); + }); + view.candidateList.replaceChildren(candidateFragment); + + const scopes = Array.isArray(preview.permissionScopes) ? preview.permissionScopes : []; + const permissionFragment = document.createDocumentFragment(); + scopes.forEach((scope) => { + const highRisk = HIGH_RISK_SCOPES.has(scope); + const label = document.createElement('label'); + label.className = 'permission-item'; + label.dataset.risk = highRisk ? 'high' : 'standard'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.name = 'permission'; + input.value = scope; + input.checked = !highRisk; + input.addEventListener('change', updateCreateButton); + const copy = document.createElement('span'); + copy.className = 'permission-copy'; + const title = document.createElement('strong'); + title.textContent = scopeLabel(scope); + const detail = document.createElement('small'); + detail.textContent = highRisk ? text('scopeHighRisk') : text('scopeStandard'); + copy.append(title, detail); + const risk = document.createElement('span'); + risk.className = 'candidate-state'; + risk.textContent = highRisk ? '!' : ''; + label.append(input, copy, risk); + permissionFragment.append(label); + }); + view.permissionList.replaceChildren(permissionFragment); + + updateCreateButton(); +} + +function renderIntakeWarnings() { + const preview = state.preview; + if (!preview) return; + const candidates = Array.isArray(preview.candidates) ? preview.candidates : []; + const selectedCount = selectedPreviewItems().length; + const warnings = []; + if (selectedCount > 1) warnings.push(text('batchSelection', { value: selectedCount })); + if (preview.truncated) warnings.push(text('truncatedCandidates')); + if ( + candidates.some((candidate) => candidate.hasImages) + && preview.model + && !preview.model.supportsImages + ) warnings.push(text('imageWarning')); + if (preview.model && preview.model.available === false) { + warnings.push(preview.model.detail || text('modelUnavailable')); + } + if (preview.workspace && preview.workspace.disposition === 'unavailable') { + warnings.push(preview.workspace.detail || text('workspaceUnavailable')); + } + if (!allRequiredPermissionScopesSelected()) warnings.push(text('selectPermissions')); + view.intakeWarning.hidden = warnings.length === 0; + view.intakeWarning.textContent = warnings.join(' '); +} + +function selectedPreviewItems() { + if (!state.preview) return []; + const selectedKeys = new Set( + [...view.candidateList.querySelectorAll('input[name="candidate"]:checked')] + .map((input) => input.value), + ); + return (state.preview.candidates || []) + .filter((candidate) => selectedKeys.has(itemKey(candidate.key))) + .map((candidate) => candidate.key); +} + +function selectedPermissionScopes() { + return [...view.permissionList.querySelectorAll('input[name="permission"]:checked')] + .map((input) => input.value); +} + +function allRequiredPermissionScopesSelected() { + const required = state.preview && Array.isArray(state.preview.permissionScopes) + ? state.preview.permissionScopes + : []; + const selected = new Set(selectedPermissionScopes()); + return required.length > 0 && required.every((scope) => selected.has(scope)); +} + +function syncSelectAll() { + const selectAll = view.candidateSelectAll; + if (!selectAll) return; + const enabled = [...view.candidateList.querySelectorAll('input[name="candidate"]:not(:disabled)')]; + const checked = [...view.candidateList.querySelectorAll('input[name="candidate"]:checked')]; + selectAll.checked = enabled.length > 0 && checked.length === enabled.length; + selectAll.indeterminate = checked.length > 0 && checked.length < enabled.length; +} + +function updateCreateButton() { + const itemCount = selectedPreviewItems().length; + const candidateCount = state.preview && Array.isArray(state.preview.candidates) + ? state.preview.candidates.length + : 0; + const previewReady = Boolean(state.preview) + && (!state.preview.model || state.preview.model.available !== false) + && (!state.preview.workspace || state.preview.workspace.disposition !== 'unavailable'); + view.createButton.disabled = itemCount === 0 || !previewReady || !allRequiredPermissionScopesSelected(); + view.createButton.textContent = itemCount > 1 + ? `${text('createTasks')} (${itemCount})` + : text('createTasks'); + view.candidateCount.textContent = text('selectedCandidates', { + selected: itemCount, + total: candidateCount, + }); + syncSelectAll(); + renderIntakeWarnings(); +} + +async function loadModelCatalog() { + const select = view.modelSelect; + if (!select || select.tagName !== 'SELECT') return; + if (!app || !app.loopx || typeof app.loopx.listModels !== 'function') return; + if (state.modelCatalogLoading) return; + if (state.modelCatalogLoaded && select.options.length > 1) return; + state.modelCatalogLoading = true; + select.dataset.loading = 'true'; + const loadingAutoOption = [...select.options].find((option) => option.value === 'auto'); + if (!state.modelCatalogLoaded && loadingAutoOption) { + loadingAutoOption.textContent = text('modelLoading'); + } + try { + const models = await app.loopx.listModels(); + const current = currentModelSelection(); + select.replaceChildren(); + const auto = document.createElement('option'); + auto.value = 'auto'; + auto.textContent = text('modelAuto'); + auto.selected = current === 'auto'; + select.appendChild(auto); + let selectedExists = current === 'auto'; + const availableModels = Array.isArray(models) ? models : []; + let renderedModelCount = 0; + for (const model of availableModels) { + if (!model || !model.id) continue; + const option = document.createElement('option'); + option.value = model.id; + const tag = model.isDefault === true ? ` · ${text('modelPrimaryTag')}` : ''; + option.textContent = `${describeModelOption(model)}${tag}`; + option.selected = current === model.id; + selectedExists = selectedExists || option.selected; + select.appendChild(option); + renderedModelCount += 1; + } + if (select.options.length === 1) { + const empty = document.createElement('option'); + empty.value = ''; + empty.textContent = text('modelEmpty'); + empty.disabled = true; + empty.dataset.status = 'empty'; + select.appendChild(empty); + } + if (!selectedExists) select.value = 'auto'; + state.modelCatalogLoaded = renderedModelCount > 0; + select.title = text('modelReloadTitle'); + } catch (error) { + const current = currentModelSelection(); + [...select.options].forEach((option) => { + if (option.dataset.status) option.remove(); + }); + if (select.options.length === 0) { + const auto = document.createElement('option'); + auto.value = 'auto'; + auto.textContent = text('modelAuto'); + select.appendChild(auto); + } else { + const auto = [...select.options].find((option) => option.value === 'auto'); + if (auto) auto.textContent = text('modelAuto'); + } + if (![...select.options].some((option) => option.dataset.status === 'load-failed')) { + const failed = document.createElement('option'); + failed.value = ''; + failed.textContent = text('modelLoadFailed'); + failed.disabled = true; + failed.dataset.status = 'load-failed'; + select.appendChild(failed); + } + select.value = current === 'auto' ? 'auto' : select.value; + select.title = errorMessage(error); + state.modelCatalogLoaded = false; + } finally { + state.modelCatalogLoading = false; + delete select.dataset.loading; + } +} + +async function resolveIntake() { + const input = view.intakeInput.value.trim(); + if (!input) { + view.intakeInput.focus(); + return; + } + if (!snapshotSupported()) { + showNotice(text('intakeUnavailable'), 'error'); + return; + } + setButtonBusy(view.resolveButton, true); + showNotice(text('resolving')); + try { + const response = await app.loopx.resolveIntake({ + input, + modelId: view.modelSelect.value, + }); + if (!response || !response.preview) throw new Error(text('previewExpired')); + await rememberIntake(input); + renderPreview(response.preview); + showNotice(''); + view.intakeDialog.showModal(); + } catch (error) { + showNotice(errorMessage(error), 'error'); + } finally { + setButtonBusy(view.resolveButton, false); + } +} + +function outcomeMessage(outcome) { + if (outcome && outcome.message) return outcome.message; + const kind = outcome && outcome.kind; + if (kind === 'created') return text('taskCreated'); + if (kind === 'opened_existing') return text('openedExisting'); + if (kind === 'closed_noop') return text('closedNoop'); + if (kind === 'needs_live_verification') return text('liveVerification'); + if (kind === 'retry_confirmation_required') return text('retryRequired'); + return kind || text('taskCreated'); +} + +function summarizeOutcomes(outcomes) { + const createdCount = outcomes.filter((outcome) => outcome.kind === 'created').length; + const messages = []; + if (createdCount === 1) messages.push(text('taskCreated')); + if (createdCount > 1) messages.push(text('tasksCreated', { value: createdCount })); + + const grouped = new Map(); + outcomes + .filter((outcome) => outcome.kind !== 'created') + .forEach((outcome) => { + const message = outcomeMessage(outcome); + grouped.set(message, (grouped.get(message) || 0) + 1); + }); + grouped.forEach((count, message) => { + messages.push(count === 1 ? message : text('outcomeCount', { message, value: count })); + }); + return messages; +} + +async function createTasks(retryTerminal) { + if (!state.preview) { + showNotice(text('previewExpired'), 'error'); + return; + } + const selectedItems = selectedPreviewItems(); + if (!selectedItems.length) { + view.intakeWarning.hidden = false; + view.intakeWarning.textContent = text('selectAtLeastOne'); + return; + } + const grantedScopes = selectedPermissionScopes(); + if (!allRequiredPermissionScopesSelected()) { + view.intakeWarning.hidden = false; + view.intakeWarning.textContent = text('selectPermissions'); + return; + } + const createRequest = { + clientRequestId: requestId(), + previewFingerprint: state.preview.fingerprint, + selectedItems, + modelId: state.preview.model && state.preview.model.modelId + ? state.preview.model.modelId + : view.modelSelect.value, + grantedScopes, + retryTerminal: Boolean(retryTerminal), + }; + state.pendingCreate = createRequest; + view.createButton.disabled = true; + view.retryConfirm.disabled = true; + try { + const response = await app.loopx.createTask(createRequest); + const outcomes = response && Array.isArray(response.outcomes) ? response.outcomes : []; + const retryOutcomes = outcomes.filter((outcome) => outcome.kind === 'retry_confirmation_required'); + if (!retryTerminal && retryOutcomes.length) { + state.pendingRetry = { + preview: state.preview, + itemKeys: new Set(retryOutcomes.map((outcome) => itemKey(outcome.item))), + scopes: grantedScopes, + }; + view.retryMessage.textContent = retryOutcomes.map(outcomeMessage).join(' '); + view.intakeDialog.close(); + view.retryDialog.showModal(); + return; + } + const messages = summarizeOutcomes(outcomes); + const hasError = outcomes.some((outcome) => outcome.kind === 'needs_live_verification'); + showNotice(messages.join(' '), hasError ? 'error' : 'success'); + view.intakeDialog.close(); + view.retryDialog.close(); + state.preview = null; + state.pendingRetry = null; + await attachSnapshot(false); + const outcomeTaskIds = outcomes + .filter((outcome) => outcome.taskId && ['created', 'opened_existing'].includes(outcome.kind)) + .map((outcome) => outcome.taskId); + const focusedTaskId = sortedTaskList( + ((state.snapshot && state.snapshot.tasks) || []) + .filter((task) => outcomeTaskIds.includes(task.taskId)), + )[0]?.taskId || outcomeTaskIds[0]; + focusTaskLogs(focusedTaskId || null); + } catch (error) { + showNotice(errorMessage(error), 'error'); + } finally { + state.pendingCreate = null; + view.retryConfirm.disabled = false; + updateCreateButton(); + } +} + +async function confirmRetry() { + const pending = state.pendingRetry; + if (!pending || !state.preview) { + view.retryDialog.close(); + showNotice(text('previewExpired'), 'error'); + return; + } + view.candidateList.querySelectorAll('input[name="candidate"]').forEach((input) => { + input.checked = pending.itemKeys.has(input.value); + }); + await createTasks(true); +} + +function openResetLoopxDialog() { + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) + ? state.snapshot.tasks.length + : 0; + view.resetLoopxMessage.textContent = text('resetLoopxMessage', { + tasks, + events: state.events.length, + }); + view.resetLoopxDialog.showModal(); +} + +async function resetLoopx() { + if (!state.snapshot || state.resetPending) return; + state.resetPending = true; + setButtonBusy(view.resetLoopx, true); + view.resetLoopxConfirm.disabled = true; + view.resetLoopxDialog.close(); + view.root.setAttribute('aria-busy', 'true'); + showNotice(text('resettingLoopxBackground')); + renderExecutionSupport(); + try { + const clientRequestId = requestId(); + await attachSnapshot(false); + view.root.setAttribute('aria-busy', 'true'); + let request = { + action: 'reset_all', + clientRequestId, + expectedRevision: Number((state.snapshot && state.snapshot.revision) || 0), + }; + let response = await app.loopx.action(request); + for (let attempt = 0; attempt < 2 && response && response.status === 'revision_conflict'; attempt += 1) { + await attachSnapshot(false); + const nextRevision = Number(response.currentRevision || (state.snapshot && state.snapshot.revision) || 0); + if (!Number.isSafeInteger(nextRevision) || nextRevision === request.expectedRevision) break; + request = { ...request, expectedRevision: nextRevision }; + response = await app.loopx.action(request); + } + if (response && response.status === 'revision_conflict') { + showNotice(response.message || text('revisionConflict'), 'error'); + } else if (response && response.status === 'rejected') { + showNotice(response.message || text('actionRejected'), 'error'); + } else { + clearRunUiState(); + showNotice(text('resetLoopxApplied'), 'success'); + } + await attachSnapshot(false); + } catch (error) { + showNotice(errorMessage(error), 'error'); + await attachSnapshot(false); + } finally { + state.resetPending = false; + setButtonBusy(view.resetLoopx, false); + view.resetLoopxConfirm.disabled = false; + view.root.setAttribute('aria-busy', 'false'); + renderExecutionSupport(); + } +} + +function openRepositoryResumeDialog() { + const target = state.repositoryResumeTarget; + if (!target || !target.tasks.length) { + showNotice(text('resumeTargetMissing'), 'error'); + return; + } + const table = COPY[localeId()] || COPY['en-US']; + const fallback = COPY['en-US']; + const reasons = []; + target.tasks.forEach((task) => { + if (!task.recoveryReason) return; + const key = `recoveryReason${String(task.recoveryReason).split('_').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')}`; + const label = table[key] || fallback[key]; + if (label && !reasons.includes(label)) reasons.push(label); + }); + view.repositoryResumeMessage.textContent = text('resumeRepositoryMessage', { + repository: repositoryLabel(target.repository), + value: target.tasks.length, + }); + if (reasons.length) { + const reasonLine = document.createElement('small'); + reasonLine.className = 'dialog-reasons'; + const separator = localeId().startsWith('zh') ? ';' : '; '; + reasonLine.textContent = reasons.join(separator); + view.repositoryResumeMessage.append(document.createElement('br'), reasonLine); + } + view.repositoryResumeDialog.showModal(); +} + +async function resumeRepository() { + const target = state.repositoryResumeTarget; + if (!target || !target.tasks.length || !state.snapshot) { + showNotice(text('resumeTargetMissing'), 'error'); + view.repositoryResumeDialog.close(); + return; + } + const count = target.tasks.length; + state.repositoryResumePending = true; + renderRepositoryActions(state.snapshot.tasks || []); + view.repositoryResumeConfirm.disabled = true; + try { + const response = await app.loopx.action({ + action: 'resume_repository', + repository: target.repository, + clientRequestId: requestId(), + expectedRevision: Number(state.snapshot.revision || 0), + }); + if (response && response.status === 'revision_conflict') { + showNotice(response.message || text('revisionConflict'), 'error'); + } else if (response && response.status === 'rejected') { + showNotice(response.message || text('actionRejected'), 'error'); + } else { + showNotice(text('resumeRepositoryApplied', { value: count }), 'success'); + } + view.repositoryResumeDialog.close(); + await attachSnapshot(false); + } catch (error) { + showNotice(errorMessage(error), 'error'); + await attachSnapshot(false); + } finally { + state.repositoryResumePending = false; + view.repositoryResumeConfirm.disabled = false; + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) + ? state.snapshot.tasks + : []; + renderRepositoryActions(tasks); + } +} + +function mergeActionTask(response) { + if (!response || !response.task || !state.snapshot) return; + const index = state.snapshot.tasks.findIndex((item) => item.taskId === response.task.taskId); + if (index >= 0) state.snapshot.tasks.splice(index, 1, response.task); + else state.snapshot.tasks.push(response.task); + state.snapshot.revision = Math.max( + Number(state.snapshot.revision || 0), + Number(response.currentRevision || 0), + Number(response.task.revision || 0), + ); +} + +function latestActionRevision(response, fallback) { + const taskRevision = Number(response && response.task && response.task.revision); + if (Number.isSafeInteger(taskRevision)) return taskRevision; + const currentRevision = Number(response && response.currentRevision); + return Number.isSafeInteger(currentRevision) ? currentRevision : fallback; +} + +async function sendActionRequest(request) { + const response = await app.loopx.action(request); + mergeActionTask(response); + return response; +} + +async function performAction(action, task, extra = {}) { + if (!snapshotSupported()) { + showNotice(text('intakeUnavailable'), 'error'); + return false; + } + const expectedRevision = task + ? Number(task.revision || 0) + : Number((state.snapshot && state.snapshot.revision) || 0); + const request = { + action, + clientRequestId: extra.clientRequestId || requestId(), + expectedRevision, + ...(task ? { taskId: task.taskId } : {}), + ...(extra.gateId ? { gateId: extra.gateId } : {}), + ...(extra.note ? { note: extra.note } : {}), + }; + if (task && task.taskId) { + state.taskActionPending.set(task.taskId, action); + renderTasks(); + renderIssueView(); + } + try { + let response = await sendActionRequest(request); + if ( + response + && response.status === 'revision_conflict' + && ((task && response.task && response.task.taskId === task.taskId) + // Snapshot-level actions (repository resume, reset) carry the fresh + // root revision in the conflict response instead of a task. + || (!task && Number.isSafeInteger(Number(response.currentRevision)))) + ) { + const nextRevision = latestActionRevision(response, request.expectedRevision); + if (nextRevision !== request.expectedRevision) { + response = await sendActionRequest({ + ...request, + expectedRevision: nextRevision, + }); + } + } + const status = response && response.status; + if (status === 'revision_conflict') { + showNotice(response.message || text('revisionConflict'), 'error'); + await attachSnapshot(false); + return false; + } else if (status === 'rejected') { + showNotice(response.message || text('actionRejected'), 'error'); + return false; + } else if (status === 'duplicate') { + showNotice( + action === 'install_loopx' + ? text('loopxInstallQueued') + : (response.message || text('actionDuplicate')), + ); + } else { + showNotice( + action === 'install_loopx' + ? text('loopxInstallQueued') + : (response && response.message ? response.message : text('actionApplied')), + 'success', + ); + await attachSnapshot(false); + } + return true; + } catch (error) { + showNotice(errorMessage(error), 'error'); + await attachSnapshot(false); + return false; + } finally { + if (task && task.taskId && state.taskActionPending.get(task.taskId) === action) { + state.taskActionPending.delete(task.taskId); + renderTasks(); + renderIssueView(); + } + } +} + +function installLoopxFromGithub() { + if (state.environmentInstallPending) return; + state.environmentInstallRequestId = state.environmentInstallRequestId || requestId(); + emitInstallDiagnostic('click_handler_entered'); + state.environmentInstallPending = true; + state.environmentInstallObserved = true; + renderExecutionSupport(); + renderEnvironment(); + showNotice(text('loopxInstallStarted')); + emitInstallDiagnostic('ui_pending_rendered'); + window.setTimeout(() => { + emitInstallDiagnostic('request_task_started'); + void submitLoopxInstallation(); + }, 50); +} + +async function submitLoopxInstallation() { + try { + emitInstallDiagnostic('bridge_call_started'); + const started = await performAction('install_loopx', null, { + clientRequestId: state.environmentInstallRequestId, + }); + emitInstallDiagnostic(started ? 'bridge_call_completed' : 'bridge_call_rejected'); + if (started) { + await attachSnapshot(false); + } else { + state.environmentInstallObserved = false; + state.environmentInstallRequestId = null; + } + } finally { + state.environmentInstallPending = false; + renderExecutionSupport(); + renderEnvironment(); + } +} + +async function answerTaskGate(task, action, note = '') { + const gate = task && latestGate(task.taskId); + if (!task || !gate) { + showNotice(text('noGate'), 'error'); + return; + } + showNotice(text('approvalSubmitting')); + try { + const applied = await performAction(action, task, { gateId: gate.gateId, note: note.trim() }); + if (applied && state.selectedTaskId === task.taskId) view.issueApprovalNote.value = ''; + } finally { + syncApprovalAttention(false); + } +} + +function approvalAlertGate() { + const task = state.approvalTaskId ? taskForId(state.approvalTaskId) : null; + const gate = task ? latestGate(task.taskId) : null; + return task && gate ? { task, gate } : null; +} + +function openApprovalAlertGate() { + const attention = approvalAlertGate(); + if (attention) selectTask(attention.task.taskId); +} + +function answerSelectedTaskGate(action) { + const task = selectedTask(); + if (!task) return; + void answerTaskGate(task, action, view.issueApprovalNote.value); +} + +const RAIL_MIN_WIDTH = 180; +const RAIL_MAX_WIDTH = 520; +const RAIL_DEFAULT_WIDTH = 286; +const RAIL_WIDTH_STORAGE_KEY = 'loopx.railWidth'; +const ISSUE_DETAIL_MIN_WIDTH = 380; +const ISSUE_DETAIL_MAX_WIDTH = 820; +const ISSUE_DETAIL_DEFAULT_WIDTH = 620; +const ISSUE_DETAIL_WIDTH_STORAGE_KEY = 'loopx.issueDetailWidth'; + +function setRailWidth(width) { + const workbench = view.taskRail.parentElement; + workbench.style.setProperty('--rail-width', `${width}px`); + state.railWidth = width; +} + +function setRailCollapsed(collapsed) { + state.railCollapsed = Boolean(collapsed); + view.taskRail.classList.toggle('is-collapsed', state.railCollapsed); + view.taskRail.parentElement.classList.toggle('tasks-collapsed', state.railCollapsed); + view.collapseTasks.setAttribute('aria-expanded', String(!state.railCollapsed)); + view.collapseTasks.setAttribute( + 'title', + state.railCollapsed ? text('expandTasks') : text('collapseTasks'), + ); +} + +function bindRailSplitter() { + const splitter = view.railSplitter; + if (!splitter) return; + let startX = 0; + let startWidth = 0; + let active = false; + const width = () => state.railWidth || RAIL_DEFAULT_WIDTH; + + const move = (event) => { + if (!active) return; + const next = startWidth + (event.clientX - startX); + setRailWidth(Math.min(RAIL_MAX_WIDTH, Math.max(RAIL_MIN_WIDTH, next))); + splitter.setAttribute('aria-valuenow', String(width())); + event.preventDefault(); + }; + + const end = () => { + if (!active) return; + active = false; + splitter.classList.remove('is-dragging'); + splitter.classList.remove('is-focused'); + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', end); + document.body.style.userSelect = ''; + splitter.setAttribute('aria-valuenow', String(width())); + persistRailWidth(); + }; + + splitter.addEventListener('pointerdown', (event) => { + if (state.railCollapsed) { + setRailCollapsed(false); + setRailWidth(Math.max(RAIL_MIN_WIDTH, width())); + } + active = true; + startX = event.clientX; + startWidth = width(); + splitter.classList.add('is-dragging'); + document.body.style.userSelect = 'none'; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', end); + event.preventDefault(); + }); + + splitter.addEventListener('focus', () => splitter.classList.add('is-focused')); + splitter.addEventListener('blur', () => splitter.classList.remove('is-focused')); + + // Double-click or double-activate resets to the default width. + splitter.addEventListener('dblclick', () => { + if (state.railCollapsed) setRailCollapsed(false); + setRailWidth(RAIL_DEFAULT_WIDTH); + splitter.setAttribute('aria-valuenow', String(width())); + persistRailWidth(); + }); + splitter.addEventListener('keydown', (event) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + if (state.railCollapsed) setRailCollapsed(false); + const step = event.shiftKey ? 48 : 12; + const next = event.key === 'ArrowRight' ? width() + step : width() - step; + setRailWidth(Math.min(RAIL_MAX_WIDTH, Math.max(RAIL_MIN_WIDTH, next))); + splitter.setAttribute('aria-valuenow', String(width())); + persistRailWidth(); + }); + + splitter.setAttribute('aria-valuemin', String(RAIL_MIN_WIDTH)); + splitter.setAttribute('aria-valuemax', String(RAIL_MAX_WIDTH)); + splitter.setAttribute('aria-valuenow', String(width())); + splitter.setAttribute('aria-controls', 'task-rail'); + + // Restore persisted width (session-only when storage is unavailable). + try { + const stored = window.localStorage.getItem(RAIL_WIDTH_STORAGE_KEY); + const parsed = stored === null ? NaN : Number(stored); + if (Number.isFinite(parsed) && parsed >= RAIL_MIN_WIDTH && parsed <= RAIL_MAX_WIDTH) { + setRailWidth(parsed); + } + } catch { + /* storage unavailable: keep default width for this session */ + } +} + +function persistRailWidth() { + const value = String(state.railWidth || RAIL_DEFAULT_WIDTH); + try { + window.localStorage.setItem(RAIL_WIDTH_STORAGE_KEY, value); + } catch { + /* storage unavailable: keep width for this session only */ + } +} + +function issueDetailBounds() { + const columns = view.issueSplitter && view.issueSplitter.parentElement; + const total = columns ? columns.getBoundingClientRect().width : 0; + const max = total > 0 + ? Math.min(ISSUE_DETAIL_MAX_WIDTH, Math.max(ISSUE_DETAIL_MIN_WIDTH, total - 360)) + : ISSUE_DETAIL_MAX_WIDTH; + return { + min: ISSUE_DETAIL_MIN_WIDTH, + max: Math.max(ISSUE_DETAIL_MIN_WIDTH, max), + }; +} + +function clampIssueDetailWidth(width) { + const bounds = issueDetailBounds(); + return Math.min(bounds.max, Math.max(bounds.min, width)); +} + +function setIssueDetailWidth(width) { + const next = clampIssueDetailWidth(width); + view.issueView.style.setProperty('--issue-detail-width', `${next}px`); + state.issueDetailWidth = next; +} + +function persistIssueDetailWidth() { + const value = String(state.issueDetailWidth || ISSUE_DETAIL_DEFAULT_WIDTH); + try { + window.localStorage.setItem(ISSUE_DETAIL_WIDTH_STORAGE_KEY, value); + } catch { + /* storage unavailable: keep width for this session only */ + } +} + +function bindIssueSplitter() { + const splitter = view.issueSplitter; + if (!splitter) return; + let startX = 0; + let startWidth = 0; + let active = false; + const width = () => state.issueDetailWidth || ISSUE_DETAIL_DEFAULT_WIDTH; + + const updateAria = () => { + const bounds = issueDetailBounds(); + splitter.setAttribute('aria-valuemin', String(bounds.min)); + splitter.setAttribute('aria-valuemax', String(bounds.max)); + splitter.setAttribute('aria-valuenow', String(width())); + }; + + const move = (event) => { + if (!active) return; + setIssueDetailWidth(startWidth + (event.clientX - startX)); + updateAria(); + event.preventDefault(); + }; + + const end = () => { + if (!active) return; + active = false; + splitter.classList.remove('is-dragging'); + splitter.classList.remove('is-focused'); + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', end); + document.body.style.userSelect = ''; + updateAria(); + persistIssueDetailWidth(); + }; + + splitter.addEventListener('pointerdown', (event) => { + active = true; + startX = event.clientX; + startWidth = width(); + splitter.classList.add('is-dragging'); + document.body.style.userSelect = 'none'; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', end); + event.preventDefault(); + }); + splitter.addEventListener('focus', () => splitter.classList.add('is-focused')); + splitter.addEventListener('blur', () => splitter.classList.remove('is-focused')); + splitter.addEventListener('dblclick', () => { + setIssueDetailWidth(ISSUE_DETAIL_DEFAULT_WIDTH); + updateAria(); + persistIssueDetailWidth(); + }); + splitter.addEventListener('keydown', (event) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const step = event.shiftKey ? 48 : 12; + const next = event.key === 'ArrowRight' ? width() + step : width() - step; + setIssueDetailWidth(next); + updateAria(); + persistIssueDetailWidth(); + }); + splitter.setAttribute('aria-controls', 'issue-detail issue-timeline'); + try { + const stored = window.localStorage.getItem(ISSUE_DETAIL_WIDTH_STORAGE_KEY); + const parsed = stored === null ? NaN : Number(stored); + setIssueDetailWidth(Number.isFinite(parsed) ? parsed : ISSUE_DETAIL_DEFAULT_WIDTH); + } catch { + setIssueDetailWidth(ISSUE_DETAIL_DEFAULT_WIDTH); + } + updateAria(); + window.addEventListener('resize', () => { + setIssueDetailWidth(width()); + updateAria(); + }); +} + +function bindEvents() { + bindRailSplitter(); + bindIssueSplitter(); + view.modelSelect.addEventListener('pointerdown', () => void loadModelCatalog()); + view.modelSelect.addEventListener('focus', () => void loadModelCatalog()); + view.modelSelect.addEventListener('change', () => { + writeStoredModelSelection(view.modelSelect.value || 'auto'); + if (state.preview) { + state.preview = null; + if (view.intakeDialog.open) view.intakeDialog.close(); + showNotice(text('modelSelectionChanged')); + } + }); + view.intakeForm.addEventListener('submit', (event) => { + event.preventDefault(); + void resolveIntake(); + }); + view.candidateSelectAll.addEventListener('change', () => { + const checked = view.candidateSelectAll.checked; + view.candidateList.querySelectorAll('input[name="candidate"]:not(:disabled)').forEach((input) => { + input.checked = checked; + }); + updateCreateButton(); + }); + view.resetLoopx.addEventListener('click', openResetLoopxDialog); + view.resetLoopxCancel.addEventListener('click', () => { + view.resetLoopxDialog.close(); + }); + view.resetLoopxConfirm.addEventListener('click', (event) => { + event.preventDefault(); + void resetLoopx(); + }); + view.installLoopx.addEventListener('pointerdown', (event) => { + if (event.button !== 0 || state.environmentInstallPending) return; + state.environmentInstallRequestId = state.environmentInstallRequestId || requestId(); + emitInstallDiagnostic('pointer_down'); + }); + view.installLoopx.addEventListener('click', () => { + void installLoopxFromGithub(); + }); + view.retryEnvironment.addEventListener('click', async () => { + await performAction('retry_environment', null); + }); + view.resumeRepository.addEventListener('click', openRepositoryResumeDialog); + view.approvalAlertOpen.addEventListener('click', openApprovalAlertGate); + view.approvalAlertOpenAction.addEventListener('click', openApprovalAlertGate); + view.issueApprovalApprove.addEventListener('click', () => answerSelectedTaskGate('approve')); + view.issueApprovalReject.addEventListener('click', () => answerSelectedTaskGate('reject')); + view.repositoryResumeCancel.addEventListener('click', () => { + view.repositoryResumeDialog.close(); + }); + view.repositoryResumeConfirm.addEventListener('click', (event) => { + event.preventDefault(); + void resumeRepository(); + }); + view.collapseTasks.addEventListener('click', () => { + setRailCollapsed(!state.railCollapsed); + }); + view.logScroll.addEventListener('scroll', () => { + const remaining = view.logScroll.scrollHeight - view.logScroll.scrollTop - view.logScroll.clientHeight; + state.followLogs = remaining < 40; + if (state.followLogs) view.newEvents.hidden = true; + }, { passive: true }); + view.newEvents.addEventListener('click', () => { + state.followLogs = true; + renderLogs(); + }); + document.querySelectorAll('.dialog-close').forEach((button) => { + button.addEventListener('click', () => button.closest('dialog').close()); + }); + view.intakeConfirmForm.addEventListener('submit', (event) => { + event.preventDefault(); + void createTasks(false); + }); + view.retryCancel.addEventListener('click', () => { + state.pendingRetry = null; + view.retryDialog.close(); + }); + view.retryConfirm.addEventListener('click', (event) => { + event.preventDefault(); + void confirmRetry(); + }); +} + +function updateLivenessClock() { + renderIssueView(); + renderTasks(); + const resumed = sampleHostClock(); + if (resumed) { + void attachSnapshot(false, true); + return; + } + const tasks = state.snapshot && Array.isArray(state.snapshot.tasks) ? state.snapshot.tasks : []; + const hasActiveWork = tasks.some((task) => [ + 'preparing', + 'running', + 'cancelling', + 'retry_wait', + ].includes(task.state)); + const now = Date.now(); + if ( + hasActiveWork + && document.visibilityState === 'visible' + && now - state.lastHostSignalAt >= STALE_ACTIVE_REATTACH_MS + && now - state.lastReattachAt >= STALE_ACTIVE_REATTACH_MS + ) { + void attachSnapshot(false); + } +} + +function sampleHostClock() { + const now = Date.now(); + const elapsed = now - state.lastClockSampleAt; + state.lastClockSampleAt = now; + return elapsed >= HOST_RESUME_GAP_MS; +} + +function handleHostSurfaceReturn() { + if (document.visibilityState !== 'visible') return; + void attachSnapshot(false, sampleHostClock()); +} + +async function start() { + bindEvents(); + applyLocale(); + void loadIntakeHistory(); + void loadModelCatalog(); + if (!app || !app.loopx) { + showBridgeUnavailable(); + return; + } + app.loopx.onEvent(onLoopxEvent); + if (typeof app.onLocaleChange === 'function') app.onLocaleChange(applyLocale); + if (typeof app.onActivate === 'function') app.onActivate(handleHostSurfaceReturn); + document.addEventListener('visibilitychange', handleHostSurfaceReturn); + window.addEventListener('focus', handleHostSurfaceReturn); + window.addEventListener('pageshow', handleHostSurfaceReturn); + window.addEventListener('online', handleHostSurfaceReturn); + window.addEventListener('beforeunload', () => { + state.tornDown = true; + clearTurnOutputTimer(); + document.removeEventListener('visibilitychange', handleHostSurfaceReturn); + window.removeEventListener('focus', handleHostSurfaceReturn); + window.removeEventListener('pageshow', handleHostSurfaceReturn); + window.removeEventListener('online', handleHostSurfaceReturn); + if (app.loopx && typeof app.loopx.offEvent === 'function') { + app.loopx.offEvent(onLoopxEvent); + } + }); + window.setInterval(updateLivenessClock, HOST_CLOCK_TICK_MS); + await attachSnapshot(true); +} + +void start(); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/worker.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/worker.js new file mode 100644 index 0000000000..497888e912 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bitfun-loopx/worker.js @@ -0,0 +1,3 @@ +// Compatibility-only worker entry. LoopX execution is intentionally owned by +// the persistent BitFun host controller; this MiniApp worker is intentionally empty. +module.exports = {}; diff --git a/src/crates/contracts/product-domains/src/miniapp/compiler.rs b/src/crates/contracts/product-domains/src/miniapp/compiler.rs index be5cd080c3..d973705586 100644 --- a/src/crates/contracts/product-domains/src/miniapp/compiler.rs +++ b/src/crates/contracts/product-domains/src/miniapp/compiler.rs @@ -1,8 +1,8 @@ //! MiniApp compiler — assemble source (html/css/ui_js) + import map + runtime bridge. use crate::miniapp::bridge_builder::{ - build_bridge_script, build_csp_content, build_import_map, build_market_csp_content, - build_miniapp_default_appearance_css, scroll_boundary_script, + build_bridge_script, build_csp_content, build_import_map, build_market_bridge_script, + build_market_csp_content, build_miniapp_default_appearance_css, scroll_boundary_script, }; use crate::miniapp::lifecycle::workspace_dir_string; use crate::miniapp::types::{MiniAppPermissions, MiniAppSource}; @@ -96,13 +96,23 @@ fn compile_internal( "linux" }; - let bridge = build_bridge_script( - app_id, - app_data_dir, - workspace_dir, - appearance_mode, - platform, - ); + let bridge = if market_strict { + build_market_bridge_script( + app_id, + app_data_dir, + workspace_dir, + appearance_mode, + platform, + ) + } else { + build_bridge_script( + app_id, + app_data_dir, + workspace_dir, + appearance_mode, + platform, + ) + }; let csp = if market_strict { build_market_csp_content().to_string() } else { @@ -443,4 +453,28 @@ mod tests { }); assert!(compile_market_with_request(&source, &permissions, &request).is_err()); } + + #[test] + fn market_compile_never_injects_private_builtin_extensions() { + let source = MiniAppSource { + html: "".to_string(), + css: String::new(), + ui_js: String::new(), + esm_dependencies: vec![], + worker_js: String::new(), + npm_dependencies: vec![], + }; + let permissions = MiniAppPermissions { + node: Some(crate::miniapp::types::NodePermissions { + enabled: false, + ..Default::default() + }), + ..Default::default() + }; + let request = + MiniAppCompileRequest::from_paths("builtin-bitfun-loopx", "/tmp/app", None, "dark"); + + let compiled = compile_market_with_request(&source, &permissions, &request).unwrap(); + assert!(!compiled.contains("loopx.attach")); + } } diff --git a/src/crates/contracts/product-domains/src/miniapp/loopx/bridge.rs b/src/crates/contracts/product-domains/src/miniapp/loopx/bridge.rs new file mode 100644 index 0000000000..cec0a56455 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/bridge.rs @@ -0,0 +1,22 @@ +use super::types::LOOPX_BUILTIN_APP_ID; + +/// Private runtime extension for the verified built-in LoopX product surface. +/// Ordinary and marketplace MiniApps never receive this source. +pub(crate) fn private_bridge_extension(app_id: &str) -> Option<&'static str> { + (app_id == LOOPX_BUILTIN_APP_ID).then_some( + r#"// Private product extension for the verified built-in LoopX surface. + // The outer bridge and Desktop host independently verify active scope, + // bundled source identity, customization origin, and execution domain. + loopx: { + attach: (opts) => _rpc('loopx.attach', opts || {}), + listModels: () => _rpc('loopx.listModels', {}), + resolveIntake: (opts) => _rpc('loopx.resolveIntake', opts || {}), + createTask: (opts) => _rpc('loopx.createTask', opts || {}), + action: (opts) => _rpc('loopx.action', opts || {}), + eventsSince: (opts) => _rpc('loopx.eventsSince', opts || {}), + turnOutputSince: (opts) => _rpc('loopx.turnOutputSince', opts || {}), + onEvent: (fn) => app.on('loopx:event', fn), + offEvent: (fn) => app.off('loopx:event', fn), + },"#, + ) +} diff --git a/src/crates/contracts/product-domains/src/miniapp/loopx/mod.rs b/src/crates/contracts/product-domains/src/miniapp/loopx/mod.rs new file mode 100644 index 0000000000..97837b89ee --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/mod.rs @@ -0,0 +1,11 @@ +//! LoopX MiniApp contracts, typed service port, and pure lifecycle policy. + +mod bridge; +pub mod policy; +pub mod ports; +pub mod types; + +pub(crate) use bridge::private_bridge_extension; +pub use policy::*; +pub use ports::*; +pub use types::*; diff --git a/src/crates/contracts/product-domains/src/miniapp/loopx/policy.rs b/src/crates/contracts/product-domains/src/miniapp/loopx/policy.rs new file mode 100644 index 0000000000..5f0437e753 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/policy.rs @@ -0,0 +1,685 @@ +//! Pure parsing and lifecycle decisions for LoopX tasks. + +use super::types::{ + LoopxActionStatus, LoopxCliGoalState, LoopxCoreEnvironmentFacts, LoopxEnvironmentFactStatus, + LoopxEnvironmentStatus, LoopxEventsPageStatus, LoopxExistingTask, LoopxIntakeCandidate, + LoopxIntakeTarget, LoopxIssueKey, LoopxItemKind, LoopxOptionalEnvironmentFacts, + LoopxPermissionScope, LoopxPhase, LoopxRemoteItemState, LoopxRepositoryKey, LoopxTaskSnapshot, + LoopxTaskState, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxIntakeParseErrorKind { + Empty, + UnsupportedHost, + InvalidRepository, + UnsupportedPath, + InvalidItemNumber, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoopxIntakeParseError { + pub kind: LoopxIntakeParseErrorKind, + pub message: String, +} + +impl LoopxIntakeParseError { + fn new(kind: LoopxIntakeParseErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +impl std::fmt::Display for LoopxIntakeParseError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for LoopxIntakeParseError {} + +fn valid_owner(value: &str) -> bool { + !value.is_empty() + && value.len() <= 39 + && !value.starts_with('-') + && !value.ends_with('-') + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') +} + +fn valid_repository(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && value.len() <= 100 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) +} + +fn split_host_and_path(input: &str) -> Result<(String, String), LoopxIntakeParseError> { + let without_suffix = input + .split(['?', '#']) + .next() + .unwrap_or_default() + .trim() + .trim_end_matches('/'); + + if let Some(rest) = without_suffix.strip_prefix("git@") { + let Some((host, path)) = rest.split_once(':') else { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::InvalidRepository, + "Invalid GitHub SSH repository URL", + )); + }; + return Ok((host.to_ascii_lowercase(), path.to_string())); + } + + let schemeless = without_suffix + .strip_prefix("https://") + .or_else(|| without_suffix.strip_prefix("http://")) + .unwrap_or(without_suffix); + + if let Some((host, path)) = schemeless.split_once('/') { + if host.eq_ignore_ascii_case("github.com") || host.eq_ignore_ascii_case("www.github.com") { + return Ok(("github.com".to_string(), path.to_string())); + } + if host.contains('.') || input.contains("://") { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::UnsupportedHost, + "Only github.com repositories are supported", + )); + } + } + + if !input.contains("://") { + return Ok(("github.com".to_string(), schemeless.to_string())); + } + + Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::UnsupportedHost, + "Only github.com repositories are supported", + )) +} + +/// Parse one GitHub repository, issues-list, issue, or pull-request target. +pub fn parse_loopx_intake(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::Empty, + "A GitHub repository, issue, or pull-request URL is required", + )); + } + if input.chars().any(char::is_whitespace) { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::UnsupportedPath, + "LoopX intake accepts one GitHub target at a time", + )); + } + + let (host, path) = split_host_and_path(input)?; + if host != "github.com" { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::UnsupportedHost, + "Only github.com repositories are supported", + )); + } + + let segments = path + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + if segments.len() < 2 { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::InvalidRepository, + "GitHub target must include an owner and repository", + )); + } + + let owner = segments[0].to_ascii_lowercase(); + let repository = segments[1] + .strip_suffix(".git") + .unwrap_or(segments[1]) + .to_ascii_lowercase(); + if !valid_owner(&owner) || !valid_repository(&repository) { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::InvalidRepository, + "Invalid GitHub owner or repository name", + )); + } + let repository_key = LoopxRepositoryKey { + host, + owner, + repository, + }; + + match segments.as_slice() { + [_, _] | [_, _, "issues"] | [_, _, "pulls"] => Ok(LoopxIntakeTarget::Repository { + repository: repository_key, + }), + [_, _, collection @ ("issues" | "pull"), number] => { + let number = number.parse::().map_err(|_| { + LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::InvalidItemNumber, + "GitHub issue or pull-request number is invalid", + ) + })?; + if number == 0 { + return Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::InvalidItemNumber, + "GitHub issue or pull-request number must be positive", + )); + } + let kind = if *collection == "issues" { + LoopxItemKind::Issue + } else { + LoopxItemKind::PullRequest + }; + Ok(LoopxIntakeTarget::Item { + item: LoopxIssueKey { + repository: repository_key, + kind, + number, + }, + }) + } + _ => Err(LoopxIntakeParseError::new( + LoopxIntakeParseErrorKind::UnsupportedPath, + "Paste a repository, issues-list, issue, or pull-request URL", + )), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "decision", rename_all = "snake_case")] +pub enum LoopxTransitionDecision { + NoChange, + Allowed { next: LoopxTaskState }, + Rejected, +} + +pub fn decide_task_transition( + current: LoopxTaskState, + next: LoopxTaskState, +) -> LoopxTransitionDecision { + if current == next { + return LoopxTransitionDecision::NoChange; + } + + let allowed = match current { + LoopxTaskState::Preparing => matches!( + next, + LoopxTaskState::Queued + | LoopxTaskState::Cancelling + | LoopxTaskState::RecoveryRequired + | LoopxTaskState::Failed + ), + LoopxTaskState::Queued => matches!( + next, + LoopxTaskState::Running + | LoopxTaskState::Cancelling + | LoopxTaskState::RecoveryRequired + | LoopxTaskState::Failed + ), + LoopxTaskState::Running => matches!( + next, + LoopxTaskState::Queued + | LoopxTaskState::WaitingForUser + | LoopxTaskState::Cancelling + | LoopxTaskState::RecoveryRequired + | LoopxTaskState::Completed + | LoopxTaskState::Failed + ), + LoopxTaskState::WaitingForUser => matches!( + next, + LoopxTaskState::Queued + | LoopxTaskState::Cancelling + | LoopxTaskState::RecoveryRequired + | LoopxTaskState::Failed + ), + LoopxTaskState::Cancelling => matches!( + next, + LoopxTaskState::Stopped + | LoopxTaskState::Aborted + | LoopxTaskState::RecoveryRequired + | LoopxTaskState::Failed + ), + LoopxTaskState::Stopped | LoopxTaskState::Failed => { + matches!(next, LoopxTaskState::Queued | LoopxTaskState::Archived) + } + LoopxTaskState::Aborted => matches!(next, LoopxTaskState::Archived), + // Legacy persisted state only: no live path re-enters RetryWait, so + // every transition from it is rejected until restart recovery requeues + // the task. + LoopxTaskState::RetryWait => false, + LoopxTaskState::RecoveryRequired => matches!( + next, + LoopxTaskState::Queued | LoopxTaskState::Stopped | LoopxTaskState::Failed + ), + LoopxTaskState::Completed => matches!(next, LoopxTaskState::Archived), + LoopxTaskState::Archived => matches!(next, LoopxTaskState::RecoveryRequired), + }; + + if allowed { + LoopxTransitionDecision::Allowed { next } + } else { + LoopxTransitionDecision::Rejected + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "decision", rename_all = "snake_case")] +pub enum LoopxRestartDecision { + Preserve { state: LoopxTaskState }, + RequireRecovery, +} + +pub fn decide_task_restart(state: LoopxTaskState) -> LoopxRestartDecision { + match state { + LoopxTaskState::Preparing | LoopxTaskState::RetryWait => LoopxRestartDecision::Preserve { + state: LoopxTaskState::Queued, + }, + state if state.was_executing_at_shutdown() => LoopxRestartDecision::RequireRecovery, + state => LoopxRestartDecision::Preserve { state }, + } +} + +pub fn task_state_after_restart(state: LoopxTaskState) -> LoopxTaskState { + match decide_task_restart(state) { + LoopxRestartDecision::RequireRecovery => LoopxTaskState::RecoveryRequired, + LoopxRestartDecision::Preserve { state } => state, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LoopxGoalProjection { + pub state: LoopxTaskState, + pub phase: LoopxPhase, +} + +/// Reconcile BitFun's local host-job projection with the authoritative LoopX +/// Goal lifecycle. Explicit local operator states and in-flight host work are +/// preserved; terminal or user-gate facts from LoopX replace stale projections. +pub fn project_host_task_from_goal( + current_state: LoopxTaskState, + current_phase: LoopxPhase, + goal_state: LoopxCliGoalState, +) -> LoopxGoalProjection { + if matches!( + current_state, + LoopxTaskState::Stopped + | LoopxTaskState::Aborted + | LoopxTaskState::Archived + | LoopxTaskState::Running + | LoopxTaskState::Cancelling + ) { + return LoopxGoalProjection { + state: current_state, + phase: current_phase, + }; + } + + match goal_state { + LoopxCliGoalState::Completed => LoopxGoalProjection { + state: LoopxTaskState::Completed, + phase: LoopxPhase::Finished, + }, + LoopxCliGoalState::Failed => LoopxGoalProjection { + state: LoopxTaskState::Failed, + phase: LoopxPhase::Finished, + }, + LoopxCliGoalState::Archived => LoopxGoalProjection { + state: LoopxTaskState::Archived, + phase: LoopxPhase::Finished, + }, + LoopxCliGoalState::WaitingForUser => LoopxGoalProjection { + state: LoopxTaskState::WaitingForUser, + phase: LoopxPhase::WaitingForApproval, + }, + LoopxCliGoalState::Active + if matches!( + current_state, + LoopxTaskState::Completed | LoopxTaskState::Failed + ) => + { + LoopxGoalProjection { + state: LoopxTaskState::RecoveryRequired, + phase: LoopxPhase::Recovering, + } + } + LoopxCliGoalState::Unknown | LoopxCliGoalState::Active => LoopxGoalProjection { + state: current_state, + phase: current_phase, + }, + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "decision", rename_all = "snake_case")] +pub enum LoopxDedupDecision { + CreateAttempt { + attempt: u32, + }, + OpenExisting { + task_id: String, + }, + RequireExplicitRetry { + previous_task_id: String, + next_attempt: u32, + }, + ClosedNoop, + NeedsLiveVerification, +} + +pub fn decide_task_dedup( + key: &LoopxIssueKey, + remote_state: LoopxRemoteItemState, + existing: &[LoopxExistingTask], + retry_terminal: bool, +) -> LoopxDedupDecision { + let mut matching = existing + .iter() + .filter(|task| &task.identity.item == key) + .collect::>(); + matching.sort_by_key(|task| task.identity.attempt); + + if let Some(active) = matching.iter().rev().find(|task| !task.state.is_terminal()) { + return LoopxDedupDecision::OpenExisting { + task_id: active.task_id.clone(), + }; + } + + if remote_state.is_resolved() { + return LoopxDedupDecision::ClosedNoop; + } + if remote_state == LoopxRemoteItemState::Unknown { + return LoopxDedupDecision::NeedsLiveVerification; + } + + let Some(previous) = matching.last() else { + return LoopxDedupDecision::CreateAttempt { attempt: 1 }; + }; + let next_attempt = previous.identity.attempt.saturating_add(1).max(1); + if retry_terminal { + LoopxDedupDecision::CreateAttempt { + attempt: next_attempt, + } + } else { + LoopxDedupDecision::RequireExplicitRetry { + previous_task_id: previous.task_id.clone(), + next_attempt, + } + } +} + +pub const LOOPX_REQUIRED_PERMISSION_SCOPES: [LoopxPermissionScope; 5] = [ + LoopxPermissionScope::WorkspaceRead, + LoopxPermissionScope::WorkspaceWrite, + LoopxPermissionScope::GitLocal, + LoopxPermissionScope::GithubRead, + LoopxPermissionScope::AgentExecution, +]; + +pub fn intake_scope_is_pregrantable(scope: LoopxPermissionScope) -> bool { + LOOPX_REQUIRED_PERMISSION_SCOPES.contains(&scope) +} + +pub fn required_permission_scopes_are_granted(granted: &[LoopxPermissionScope]) -> bool { + LOOPX_REQUIRED_PERMISSION_SCOPES + .iter() + .all(|scope| granted.contains(scope)) +} + +/// The agent concluded the reported failure was already fixed upstream, so the +/// issue needs no follow-up work. Such tasks are excluded from the repository +/// recovery candidates: resuming them would only re-run a no-op investigation. +/// Mirrors the bitfun-loopx MiniApp UI heuristic (`isResolvedUpstream`); keep +/// the two in sync. +pub fn task_summary_resolves_upstream(summary: Option<&str>) -> bool { + use std::sync::OnceLock; + static PATTERNS: OnceLock> = OnceLock::new(); + let Some(summary) = summary else { + return false; + }; + let summary = summary.trim(); + if summary.is_empty() { + return false; + } + PATTERNS + .get_or_init(|| { + [ + r"(?is)covered[-_ ]?upstream.{0,80}no[-_ ]?follow[-_ ]?up", + r"(?is)原始故障路径.{0,40}(?:消失|移除).{0,120}(?:不开\s*PR|无需.{0,20}修复)", + ] + .into_iter() + .map(|pattern| regex::Regex::new(pattern).expect("static resolved-upstream pattern")) + .collect() + }) + .iter() + .any(|pattern| pattern.is_match(summary)) +} + +/// Classifies a LoopX action kind as monitor-class: the todo waits for an +/// external event the agent cannot advance itself (PR merge readiness, PR +/// state watches, continuous monitoring). Covers `continuous_monitor`, the +/// `*_monitor` family, and the `issue_fix_track_*` merge-readiness trackers. +/// The host holds monitor re-checks back with the compatibility cadence +/// instead of driving back-to-back turns, and the MiniApp UI projects the +/// "PR monitor waiting" state with the same rule (`isMonitorTodo` in the +/// bitfun-loopx UI); keep the two in sync. +pub fn is_loopx_monitor_action(action_kind: &str) -> bool { + let kind = action_kind.trim(); + !kind.is_empty() && (kind.ends_with("_monitor") || kind.starts_with("issue_fix_track_")) +} + +/// Repository resume candidates: resumable states on the same repository that +/// the agent has not already concluded are resolved upstream. The MiniApp +/// repository resume dialog counts with the same rule, so the confirmed count +/// always matches the tasks the controller actually queues. +pub fn decide_repository_recovery_candidate(task: &LoopxTaskSnapshot, repository_id: &str) -> bool { + task.identity.item.repository.canonical_id() == repository_id + && matches!( + task.state, + LoopxTaskState::Stopped | LoopxTaskState::Failed | LoopxTaskState::RecoveryRequired + ) + && !task_summary_resolves_upstream(task.last_agent_summary.as_deref()) +} + +const SUMMARY_SCHEMA_MARKER: &str = "loopx_summary_v1"; +const SUMMARY_VERDICTS: [&str; 4] = [ + "needs_fix", + "already_fixed_upstream", + "wont_fix", + "needs_info", +]; +const SUMMARY_REPRODUCTIONS: [&str; 3] = ["reproduced", "not_reproduced", "not_applicable"]; +const SUMMARY_SEGMENT_KINDS: [&str; 5] = [ + "evidence", + "route_decision", + "implementation", + "validation", + "delivery", +]; + +/// Extract and validate the `loopx_summary_v1` fenced JSON block from an agent +/// summary. Returns None when the block is absent or violates the schema +/// (unknown enum value, or a verdict missing its conditional evidence); the +/// caller then falls back to rendering the raw text. Approval and gate state +/// are intentionally not part of the schema — the host-projected gate card is +/// the single expression and operation surface for them. +pub fn parse_structured_summary(summary: Option<&str>) -> Option { + let summary = summary?; + let marker = format!("```{SUMMARY_SCHEMA_MARKER}"); + let start = summary.find(&marker)?; + let body_start = summary[start..].find('\n')? + start + 1; + let body_end = summary[body_start..].find("```")? + body_start; + let body = summary[body_start..body_end].trim(); + let value: serde_json::Value = serde_json::from_str(body).ok()?; + let object = value.as_object()?; + + let verdict = object.get("issue_verdict")?.as_str()?; + if !SUMMARY_VERDICTS.contains(&verdict) { + return None; + } + // Conditional evidence: a verdict that claims facts must carry them, or the + // block is malformed (single-source-of-truth: the verdict is the only + // place upstream fix state can be expressed). + match verdict { + "already_fixed_upstream" => { + let fixed_by = object + .get("fixed_by") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if fixed_by.trim().is_empty() { + return None; + } + } + "wont_fix" => { + let reason = object + .get("wont_fix_reason") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if reason.trim().is_empty() { + return None; + } + } + "needs_info" => { + let missing = object.get("missing_info").and_then(|v| v.as_array())?; + if missing.is_empty() { + return None; + } + } + _ => {} + } + + if let Some(reproduction) = object.get("reproduction").and_then(|v| v.as_str()) { + if !SUMMARY_REPRODUCTIONS.contains(&reproduction) { + return None; + } + if reproduction == "reproduced" { + let evidence = object + .get("reproduction_evidence") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if evidence.trim().is_empty() { + return None; + } + } + } + if let Some(kind) = object.get("segment_kind").and_then(|v| v.as_str()) { + if !SUMMARY_SEGMENT_KINDS.contains(&kind) { + return None; + } + } + Some(value) +} + +pub fn derive_environment_status( + core: &LoopxCoreEnvironmentFacts, + optional: &LoopxOptionalEnvironmentFacts, +) -> LoopxEnvironmentStatus { + let core_statuses = [ + core.sidecar.status, + core.git_worktree.status, + core.agent_model.status, + ]; + if core_statuses.contains(&LoopxEnvironmentFactStatus::Checking) { + return LoopxEnvironmentStatus::Checking; + } + if core_statuses.contains(&LoopxEnvironmentFactStatus::Unavailable) { + return LoopxEnvironmentStatus::Blocked; + } + if core_statuses.contains(&LoopxEnvironmentFactStatus::Unknown) { + return LoopxEnvironmentStatus::Unknown; + } + if core_statuses.contains(&LoopxEnvironmentFactStatus::Degraded) { + return LoopxEnvironmentStatus::Degraded; + } + + let optional_statuses = [optional.python_fallback.status, optional.github_auth.status]; + if optional_statuses.iter().any(|status| { + matches!( + status, + LoopxEnvironmentFactStatus::Degraded | LoopxEnvironmentFactStatus::Unavailable + ) + }) { + LoopxEnvironmentStatus::Degraded + } else { + LoopxEnvironmentStatus::Ready + } +} + +pub fn decide_action_status( + client_request_already_applied: bool, + expected_revision: u64, + current_revision: u64, +) -> LoopxActionStatus { + if client_request_already_applied { + LoopxActionStatus::Duplicate + } else if expected_revision != current_revision { + LoopxActionStatus::RevisionConflict + } else { + LoopxActionStatus::Applied + } +} + +pub fn decide_events_page_status( + current_stream_id: &str, + requested_stream_id: &str, + after_cursor: u64, + oldest_retained_cursor: Option, + latest_cursor: u64, +) -> LoopxEventsPageStatus { + if current_stream_id != requested_stream_id || after_cursor > latest_cursor { + return LoopxEventsPageStatus::SnapshotRequired; + } + if let Some(oldest) = oldest_retained_cursor { + if after_cursor.saturating_add(1) < oldest { + return LoopxEventsPageStatus::SnapshotRequired; + } + } + LoopxEventsPageStatus::Current +} + +pub fn build_intake_fingerprint( + target: &LoopxIntakeTarget, + candidates: &[LoopxIntakeCandidate], + workspace_path: Option<&str>, + model_id: &str, + permission_scopes: &[LoopxPermissionScope], +) -> String { + let mut item_facts = candidates + .iter() + .map(|candidate| { + format!( + "{}:{:?}:{}:{}", + candidate.key.canonical_id(), + candidate.state, + candidate.has_images, + candidate.from_repository + ) + }) + .collect::>(); + item_facts.sort(); + let mut scopes = permission_scopes.to_vec(); + scopes.sort(); + scopes.dedup(); + let target_id = match target { + LoopxIntakeTarget::Repository { repository } => repository.canonical_id(), + LoopxIntakeTarget::Item { item } => item.canonical_id(), + }; + let payload = format!( + "target={target_id}\nitems={}\nworkspace={}\nmodel={model_id}\nscopes={scopes:?}", + item_facts.join("|"), + workspace_path.unwrap_or_default(), + ); + format!("sha256:{}", hex::encode(Sha256::digest(payload.as_bytes()))) +} diff --git a/src/crates/contracts/product-domains/src/miniapp/loopx/ports.rs b/src/crates/contracts/product-domains/src/miniapp/loopx/ports.rs new file mode 100644 index 0000000000..f958db6886 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/ports.rs @@ -0,0 +1,904 @@ +//! Narrow service boundary for the pinned LoopX CLI adapter. + +use super::types::{ + LoopxCliGoalState, LoopxCurrentTodo, LoopxEventCursor, LoopxIntakeCandidate, LoopxIntakeTarget, + LoopxIssueKey, LoopxPermissionScope, LoopxRemoteItemState, LoopxRepositoryKey, + LoopxTurnOutputEvent, +}; +use serde::{Deserialize, Serialize}; +use std::future::Future; +use std::pin::Pin; + +pub type LoopxCliFuture<'a, T> = Pin> + Send + 'a>>; +pub type LoopxCliResult = Result; + +fn default_loopx_version() -> String { + super::types::LOOPX_PINNED_VERSION.to_string() +} + +fn default_cli_schema_version() -> u32 { + super::types::LOOPX_CLI_SCHEMA_VERSION +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliErrorKind { + #[default] + Backend, + InvalidInput, + NotFound, + VersionMismatch, + SchemaMismatch, + Conflict, + Cancelled, + Timeout, + Process, + Io, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliError { + pub kind: LoopxCliErrorKind, + pub message: String, + pub operation_id: Option, + pub retryable: bool, +} + +impl LoopxCliError { + pub fn new(kind: LoopxCliErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + operation_id: None, + retryable: false, + } + } + + pub fn for_operation(mut self, operation_id: impl Into) -> Self { + self.operation_id = Some(operation_id.into()); + self + } + + pub fn retryable(mut self, retryable: bool) -> Self { + self.retryable = retryable; + self + } +} + +impl std::fmt::Display for LoopxCliError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{:?}: {}", self.kind, self.message) + } +} + +impl std::error::Error for LoopxCliError {} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliCallContext { + pub operation_id: String, + pub deadline_at: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliGoalContext { + #[serde(flatten)] + pub call: LoopxCliCallContext, + pub task_id: String, + pub generation: u64, + pub worktree_path: String, + pub registry_path: String, + /// Execution capabilities exposed by the selected Agent host. These are + /// observed technical facts, not permission grants. + pub available_capabilities: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliProgressStage { + #[default] + StartingSidecar, + InstallingRuntime, + Handshake, + ResolvingIntake, + PlanningItem, + CreatingGoal, + InspectingGoal, + BuildingTurn, + AnsweringGate, + SettlingTurn, + Cancelling, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliProgress { + pub operation_id: String, + pub task_id: Option, + pub stage: LoopxCliProgressStage, + pub message: String, + pub occurred_at: i64, +} + +/// Synchronous projection hook; the controller persists and broadcasts events. +pub trait LoopxCliProgressSink: Send + Sync { + fn report(&self, progress: LoopxCliProgress); +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliSource { + #[default] + Unknown, + Bundled, + System, + PythonFallback, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliExecutableIdentity { + pub source: LoopxCliSource, + /// Adapter-owned executable identifier; never an argv fragment. + pub identity: String, + pub path: Option, + pub sha256: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliHandshakeRequest { + #[serde(flatten)] + pub call: LoopxCliCallContext, + #[serde(default = "default_loopx_version")] + pub required_loopx_version: String, + #[serde(default = "default_cli_schema_version")] + pub required_schema_version: u32, +} + +impl Default for LoopxCliHandshakeRequest { + fn default() -> Self { + Self { + call: LoopxCliCallContext::default(), + required_loopx_version: default_loopx_version(), + required_schema_version: default_cli_schema_version(), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliManifest { + pub adapter_version: String, + pub loopx_version: String, + pub schema_version: u32, + pub executable: LoopxCliExecutableIdentity, + pub capabilities: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliInstallManagedSourceRequest { + #[serde(flatten)] + pub call: LoopxCliCallContext, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliInstallManagedSourceResult { + pub source_repository: String, + pub source_tag: String, + pub source_commit: String, + pub install_path: String, + pub loopx_version: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliTodoPlan { + pub role: String, + pub task_class: String, + pub action_kind: Option, + pub text: String, + /// Stable workflow target key for this todo, when provided. + pub target_key: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliResolveIntakeRequest { + #[serde(flatten)] + pub call: LoopxCliCallContext, + pub input: String, + pub target: LoopxIntakeTarget, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliResolveIntakeResult { + pub target: LoopxIntakeTarget, + pub repository: LoopxRepositoryKey, + pub candidates: Vec, + pub truncated: bool, + pub resolved_at: i64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxGithubAuthProbeRequest { + #[serde(flatten)] + pub call: LoopxCliCallContext, +} + +/// Result of the pre-flight GitHub access probe. The host surfaces this as the +/// `github_auth` environment fact so an auth/rate-limit failure is visible +/// before the user submits an intake, instead of surfacing as a 403 later. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxGithubAuthProbe { + /// Whether an authenticated GitHub identity is available. + pub authenticated: bool, + /// Remaining core API rate limit, when the endpoint reports one. + pub rate_limit_remaining: Option, + pub detail: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliPlanItemRequest { + #[serde(flatten)] + pub context: LoopxCliGoalContext, + pub item: LoopxIssueKey, + /// Public title already resolved by the host intake adapter. The LoopX + /// process receives this as inline metadata and must not refetch it. + pub title: String, + /// Remote item state observed by the host intake adapter. LoopX treats + /// GitHub as the source of truth, so the workflow plan must receive the + /// state the adapter actually resolved instead of a fabricated default. + pub state: LoopxRemoteItemState, + /// Bounded label names observed by the host intake adapter (capped to + /// match LoopX's own metadata projection). LoopX intake classification + /// and code-context routing use these as routing hints. + pub labels: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliIntakePlan { + pub item: LoopxIssueKey, + pub objective: String, + pub todos: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliCreateGoalRequest { + #[serde(flatten)] + pub context: LoopxCliGoalContext, + pub goal_id: String, + pub agent_id: String, + pub intake: LoopxCliIntakePlan, + pub granted_scopes: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliCreateGoalResult { + pub goal_id: String, + pub created: bool, + pub durable_revision: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliInspectGoalRequest { + #[serde(flatten)] + pub context: LoopxCliGoalContext, + pub goal_id: String, + pub agent_id: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliRunDecision { + #[default] + Wait, + RunNow, + WaitingForUser, + Complete, + Failed, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliUserGate { + pub gate_id: String, + pub message: String, + pub action_kind: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliGoalSnapshot { + pub goal_id: String, + pub state: LoopxCliGoalState, + pub durable_revision: String, + pub run_decision: LoopxCliRunDecision, + pub scheduler_hint_ms: Option, + pub open_todo_count: u32, + pub waiting_user_todo_count: u32, + pub pending_user_gate: Option, + /// Read-only projection of the frontier todo selected by the same LoopX + /// envelope. Absent when LoopX did not select a todo; never authoritative. + pub selected_todo: Option, + /// Read-only projection of the autonomous replan obligation the same + /// envelope carries (`replan_action_packet.obligation_id`). When the + /// plan runs dry (no open todo, no selected todo) the pinned CLI still + /// projects `should_run=true` with this obligation and expects the host + /// to drive one bounded autonomous replan turn bound to it: the agent + /// writes back a successor todo, a typed terminal outcome, or a concrete + /// blocker through the replan writeback. Only a todo-less `RunNow` + /// frontier WITHOUT an open obligation is a host contract contradiction. + /// Absent when the envelope carries no replan obligation. + pub pending_replan_obligation_id: Option, + /// LoopX returned the turn envelope over its compaction budget + /// (`compaction.within_budget == false`, route `contract_error`): the goal + /// cannot be planned until its durable state shrinks. The host must not + /// fail the task for this; it degrades to a loud backoff-and-retry wait. + pub envelope_over_budget: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliBuildTurnRequest { + #[serde(flatten)] + pub context: LoopxCliGoalContext, + pub goal_id: String, + pub agent_id: String, + pub expected_durable_revision: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliBuildTurnResult { + pub goal_id: String, + pub turn_id: String, + /// Stable custom-runner re-entry instruction derived from the fresh LoopX + /// TurnEnvelope. It is not a cached LoopX packet or rewritten heartbeat. + #[serde(alias = "prompt")] + pub agent_instruction: String, + pub settlement_token: String, + pub durable_revision: String, + pub deadline_at: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliGateDecision { + #[default] + Approve, + Reject, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliAnswerGateRequest { + #[serde(flatten)] + pub context: LoopxCliGoalContext, + pub goal_id: String, + pub agent_id: String, + pub gate_id: String, + pub decision: LoopxCliGateDecision, + pub note: Option, + pub granted_scope: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliAnswerGateResult { + pub goal_id: String, + pub gate_id: String, + pub applied: bool, + pub durable_revision: String, + pub goal_state: LoopxCliGoalState, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxAgentTurnStatus { + #[default] + Completed, + Failed, + Cancelled, + Interrupted, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliSettleTurnRequest { + #[serde(flatten)] + pub context: LoopxCliGoalContext, + pub goal_id: String, + pub agent_id: String, + pub turn_id: String, + pub settlement_token: String, + pub expected_durable_revision: String, + pub agent_status: LoopxAgentTurnStatus, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliSettlementStatus { + #[default] + Settled, + AlreadySettled, + NoDurableProgress, + RetryRequired, + GoalCompleted, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliSettleTurnResult { + pub goal_id: String, + pub turn_id: String, + pub receipt_id: String, + pub status: LoopxCliSettlementStatus, + pub before_revision: String, + pub after_revision: String, + pub validation_succeeded: bool, + pub scheduler_hint_ms: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliCancelRequest { + #[serde(flatten)] + pub call: LoopxCliCallContext, + pub target_operation_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliCancelResult { + pub operation_id: String, + pub target_operation_id: String, + pub cancelled: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliResetGoalsRequest { + #[serde(flatten)] + pub call: LoopxCliCallContext, + pub goal_ids: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCliResetGoalsResult { + pub requested_goal_ids: Vec, + pub retired_goal_ids: Vec, + pub already_absent_goal_ids: Vec, + pub archived_goal_ids: Vec, + pub missing_runtime_goal_ids: Vec, + pub backup_paths: Vec, + pub archive_paths: Vec, +} + +/// Typed LoopX operations. No caller can pass raw CLI arguments through this port. +pub trait LoopxCliPort: Send + Sync { + fn install_managed_source<'a>( + &'a self, + request: LoopxCliInstallManagedSourceRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliInstallManagedSourceResult>; + + fn handshake<'a>( + &'a self, + request: LoopxCliHandshakeRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliManifest>; + + fn resolve_intake<'a>( + &'a self, + request: LoopxCliResolveIntakeRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliResolveIntakeResult>; + + fn probe_github_auth<'a>( + &'a self, + request: LoopxGithubAuthProbeRequest, + ) -> LoopxCliFuture<'a, LoopxGithubAuthProbe>; + + fn plan_item<'a>( + &'a self, + request: LoopxCliPlanItemRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliIntakePlan>; + + fn create_goal<'a>( + &'a self, + request: LoopxCliCreateGoalRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliCreateGoalResult>; + + fn inspect_goal<'a>( + &'a self, + request: LoopxCliInspectGoalRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliGoalSnapshot>; + + fn build_turn<'a>( + &'a self, + request: LoopxCliBuildTurnRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliBuildTurnResult>; + + fn answer_gate<'a>( + &'a self, + request: LoopxCliAnswerGateRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliAnswerGateResult>; + + /// Probes whether the authenticated GitHub identity can merge pull + /// requests in the goal's repository. `Ok(None)` means unknown (no + /// credential, probe failure, or unsupported provider); callers must fail + /// open to the interactive gate instead of guessing. + fn viewer_merge_authority<'a>( + &'a self, + context: &'a LoopxCliGoalContext, + repository: &'a LoopxRepositoryKey, + ) -> LoopxCliFuture<'a, Option>; + + /// Verify one external-host turn from LoopX-owned durable writeback. This + /// read boundary must never repair, synthesize, or spend on the Agent's + /// behalf. + fn verify_turn_settlement<'a>( + &'a self, + request: LoopxCliSettleTurnRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliSettleTurnResult>; + + /// Retires explicit global routes and archives their runtime state after + /// an explicit product reset has removed the corresponding workspaces. + fn reset_goals<'a>( + &'a self, + request: LoopxCliResetGoalsRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliResetGoalsResult>; + + fn cancel<'a>( + &'a self, + request: LoopxCliCancelRequest, + progress: &'a dyn LoopxCliProgressSink, + ) -> LoopxCliFuture<'a, LoopxCliCancelResult>; +} + +pub type LoopxHostFuture<'a, T> = Pin> + Send + 'a>>; +pub type LoopxHostResult = Result; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxHostPortErrorKind { + #[default] + Backend, + InvalidInput, + NotFound, + Unsupported, + Conflict, + Cancelled, + Timeout, + Io, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxHostPortError { + pub kind: LoopxHostPortErrorKind, + pub message: String, + pub operation_id: Option, + pub retryable: bool, +} + +impl LoopxHostPortError { + pub fn new(kind: LoopxHostPortErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + operation_id: None, + retryable: false, + } + } +} + +impl std::fmt::Display for LoopxHostPortError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{:?}: {}", self.kind, self.message) + } +} + +impl std::error::Error for LoopxHostPortError {} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspacePrepareRequest { + pub operation_id: String, + pub task_id: String, + pub item: LoopxIssueKey, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceProbeRequest { + pub operation_id: String, + /// When present, also verifies that Git can read the canonical repository. + pub repository: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceProbeResult { + pub git_version: Option, + pub workspace_root: String, + pub repository_verified: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspacePrepareResult { + pub worktree_path: String, + pub registry_path: String, + pub reused: bool, + pub repository_verified: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceVerifyRequest { + pub operation_id: String, + pub task_id: String, + pub item: LoopxIssueKey, + pub worktree_path: String, + pub registry_path: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceVerifyResult { + pub valid: bool, + pub repository: Option, + pub message: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceCancelRequest { + pub operation_id: String, + pub target_operation_id: String, + pub task_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceCancelResult { + pub target_operation_id: String, + pub cancelled: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceDisposeRequest { + pub operation_id: String, + pub task_id: String, + pub item: LoopxIssueKey, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceDisposeResult { + /// The worktree (and, when it was the last reference, the shared bare + /// repository) was removed from disk. + pub removed: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceResetRequest { + pub operation_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspaceResetResult { + /// Task worktrees were detached from the active root. Implementations may + /// reclaim them asynchronously and retain verified bare Git object caches. + pub removed: bool, +} + +/// Creates or reuses an isolated worktree for exactly one canonical item. +pub trait LoopxWorkspacePort: Send + Sync { + fn probe( + &self, + request: LoopxWorkspaceProbeRequest, + ) -> LoopxHostFuture<'_, LoopxWorkspaceProbeResult>; + + fn prepare( + &self, + request: LoopxWorkspacePrepareRequest, + ) -> LoopxHostFuture<'_, LoopxWorkspacePrepareResult>; + + fn verify( + &self, + request: LoopxWorkspaceVerifyRequest, + ) -> LoopxHostFuture<'_, LoopxWorkspaceVerifyResult>; + + fn cancel( + &self, + request: LoopxWorkspaceCancelRequest, + ) -> LoopxHostFuture<'_, LoopxWorkspaceCancelResult>; + + /// Removes the task worktree after terminal settlement. With the shared + /// bare-repository layout this also removes the bare repo once its last + /// worktree is gone. The caller must guarantee the task is terminal. + fn dispose( + &self, + request: LoopxWorkspaceDisposeRequest, + ) -> LoopxHostFuture<'_, LoopxWorkspaceDisposeResult>; + + /// Removes every managed LoopX workspace and goal registry under the + /// service-owned root. This is reserved for explicit full-reset actions. + fn reset( + &self, + request: LoopxWorkspaceResetRequest, + ) -> LoopxHostFuture<'_, LoopxWorkspaceResetResult>; +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentTurnMetadata { + pub goal_id: String, + pub loopx_turn_id: String, + pub item: LoopxIssueKey, + pub attempt: u32, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentStartRequest { + pub operation_id: String, + pub task_id: String, + pub generation: u64, + pub worktree_path: String, + #[serde(alias = "prompt")] + pub instruction: String, + pub model_id: String, + pub granted_scopes: Vec, + pub metadata: LoopxAgentTurnMetadata, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentStartResult { + pub session_id: String, + pub turn_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentProbeRequest { + pub operation_id: String, + /// `None`, `auto`, and `primary` all validate the configured primary model. + pub model_id: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentProbeResult { + pub model_id: String, + pub supports_images: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentCancelRequest { + pub operation_id: String, + pub target_operation_id: String, + pub task_id: String, + pub generation: u64, + pub session_id: String, + pub turn_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentCancelResult { + pub target_operation_id: String, + pub cancelled: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentFinishRequest { + pub operation_id: String, + pub task_id: String, + pub generation: u64, + pub worktree_path: String, + pub session_id: String, + pub turn_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentFinishResult { + pub session_id: String, + pub discarded: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentResetRequest { + pub operation_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentResetResult { + pub removed_runtime_event_logs: u32, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentOutputSinceRequest { + pub operation_id: String, + pub session_id: String, + pub turn_id: String, + pub stream_id: Option, + pub after_cursor: LoopxEventCursor, + pub limit: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAgentOutputSinceResult { + pub stream_id: Option, + pub events: Vec, + pub next_cursor: LoopxEventCursor, + pub has_more: bool, +} + +/// Starts fresh transient Agent sessions bound to the prepared worktree. +pub trait LoopxAgentPort: Send + Sync { + /// Reports technical execution capabilities of this concrete Agent host. + /// These facts never imply user permission for an individual task. + fn available_capabilities(&self) -> Vec; + + fn probe(&self, request: LoopxAgentProbeRequest) -> LoopxHostFuture<'_, LoopxAgentProbeResult>; + + fn start(&self, request: LoopxAgentStartRequest) -> LoopxHostFuture<'_, LoopxAgentStartResult>; + + fn cancel( + &self, + request: LoopxAgentCancelRequest, + ) -> LoopxHostFuture<'_, LoopxAgentCancelResult>; + + /// Discards the fresh transient session after terminal settlement. + fn finish( + &self, + request: LoopxAgentFinishRequest, + ) -> LoopxHostFuture<'_, LoopxAgentFinishResult>; + + /// Removes LoopX-owned transient Agent diagnostics after every active + /// session has been cancelled and discarded. + fn reset(&self, request: LoopxAgentResetRequest) -> LoopxHostFuture<'_, LoopxAgentResetResult>; + + /// Reads the in-flight Agent turn output projection. Completed turns may + /// already have handed their data back to session persistence and discarded + /// this transient projection. + fn output_since( + &self, + request: LoopxAgentOutputSinceRequest, + ) -> LoopxHostFuture<'_, LoopxAgentOutputSinceResult>; +} diff --git a/src/crates/contracts/product-domains/src/miniapp/loopx/types.rs b/src/crates/contracts/product-domains/src/miniapp/loopx/types.rs new file mode 100644 index 0000000000..a10952624f --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/loopx/types.rs @@ -0,0 +1,804 @@ +//! Stable LoopX MiniApp wire types. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub const LOOPX_BUILTIN_APP_ID: &str = "builtin-bitfun-loopx"; +pub const LOOPX_PINNED_VERSION: &str = "0.5.1"; +pub const LOOPX_CLI_SCHEMA_VERSION: u32 = 1; + +pub type LoopxEventCursor = u64; + +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxRepositoryKey { + pub host: String, + pub owner: String, + pub repository: String, +} + +impl LoopxRepositoryKey { + pub fn canonical_id(&self) -> String { + format!("{}/{}/{}", self.host, self.owner, self.repository) + } + + pub fn label(&self) -> String { + format!("{}/{}", self.owner, self.repository) + } +} + +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum LoopxItemKind { + #[default] + Issue, + #[serde(rename = "pr", alias = "pull_request")] + PullRequest, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxIssueKey { + pub repository: LoopxRepositoryKey, + pub kind: LoopxItemKind, + pub number: u64, +} + +impl LoopxIssueKey { + pub fn canonical_id(&self) -> String { + let collection = match self.kind { + LoopxItemKind::Issue => "issues", + LoopxItemKind::PullRequest => "pull", + }; + format!( + "{}/{}/{}", + self.repository.canonical_id(), + collection, + self.number + ) + } + + pub fn canonical_url(&self) -> String { + format!("https://{}", self.canonical_id()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "targetType", rename_all = "snake_case")] +pub enum LoopxIntakeTarget { + Repository { repository: LoopxRepositoryKey }, + Item { item: LoopxIssueKey }, +} + +impl Default for LoopxIntakeTarget { + fn default() -> Self { + Self::Repository { + repository: LoopxRepositoryKey::default(), + } + } +} + +impl LoopxIntakeTarget { + pub fn repository(&self) -> &LoopxRepositoryKey { + match self { + Self::Repository { repository } => repository, + Self::Item { item } => &item.repository, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxRemoteItemState { + Open, + Closed, + Merged, + #[default] + #[serde(other)] + Unknown, +} + +impl LoopxRemoteItemState { + pub fn is_resolved(self) -> bool { + matches!(self, Self::Closed | Self::Merged) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxIntakeCandidate { + pub key: LoopxIssueKey, + pub url: String, + pub title: String, + /// Bounded plain-text excerpt of the issue/PR body, kept for task + /// surfaces. The projection intentionally never retains the full remote + /// body (only this trimmed excerpt) to bound snapshot size. + pub description: String, + pub state: LoopxRemoteItemState, + pub state_reason: Option, + /// Bounded label names from the remote item (capped to match LoopX's own + /// metadata projection). Empty for legacy snapshots. + pub labels: Vec, + pub from_repository: bool, + pub has_images: bool, + /// Repository/list intake must not silently select every candidate. + pub default_selected: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxWorkspaceDisposition { + ExistingWorktree, + NewWorktree, + CloneRequired, + #[default] + Unavailable, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxWorkspacePreview { + pub disposition: LoopxWorkspaceDisposition, + pub path: Option, + pub repository_verified: bool, + pub detail: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxModelCapability { + pub model_id: String, + pub available: bool, + pub supports_images: bool, + pub detail: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxPermissionScope { + WorkspaceRead, + WorkspaceWrite, + GitLocal, + GithubRead, + AgentExecution, + Publish, + PublicComment, + PullRequest, + Merge, + ProductionAction, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxIntakePreview { + pub fingerprint: String, + pub target: LoopxIntakeTarget, + pub repository: LoopxRepositoryKey, + pub workspace: LoopxWorkspacePreview, + pub candidates: Vec, + pub truncated: bool, + pub model: LoopxModelCapability, + pub permission_scopes: Vec, + pub resolved_at: i64, + pub expires_at: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxEnvironmentFactStatus { + Checking, + Available, + Degraded, + Unavailable, + #[default] + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxEnvironmentRemediationAction { + InstallLoopx, + #[default] + #[serde(other)] + None, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxEnvironmentFact { + pub status: LoopxEnvironmentFactStatus, + pub version: Option, + pub detail: Option, + pub remediation: Option, + pub remediation_action: LoopxEnvironmentRemediationAction, + pub checked_at: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCoreEnvironmentFacts { + pub sidecar: LoopxEnvironmentFact, + pub git_worktree: LoopxEnvironmentFact, + pub agent_model: LoopxEnvironmentFact, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxOptionalEnvironmentFacts { + pub python_fallback: LoopxEnvironmentFact, + pub github_auth: LoopxEnvironmentFact, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxEnvironmentStatus { + Checking, + Ready, + Degraded, + Blocked, + #[default] + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxEnvironmentSnapshot { + pub revision: u64, + pub status: LoopxEnvironmentStatus, + pub core: LoopxCoreEnvironmentFacts, + pub optional: LoopxOptionalEnvironmentFacts, + pub checked_at: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxTaskState { + Preparing, + Queued, + Running, + WaitingForUser, + RetryWait, + Cancelling, + Stopped, + Aborted, + Completed, + Failed, + Archived, + #[default] + #[serde(other)] + RecoveryRequired, +} + +/// Authoritative Goal lifecycle projected from the LoopX CLI. This is kept +/// separate from [`LoopxTaskState`], which describes BitFun's local host job +/// (workspace, Agent session, cancellation, and recovery lifecycle). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCliGoalState { + #[default] + Unknown, + Active, + WaitingForUser, + Completed, + Failed, + Archived, +} + +impl LoopxTaskState { + pub fn is_terminal(self) -> bool { + matches!( + self, + Self::Stopped | Self::Aborted | Self::Completed | Self::Failed | Self::Archived + ) + } + + pub fn was_executing_at_shutdown(self) -> bool { + matches!(self, Self::Running | Self::Cancelling) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxPhase { + ValidatingEnvironment, + ResolvingIntake, + PreparingWorkspace, + CreatingGoal, + Queued, + InspectingGoal, + BuildingTurn, + StartingAgent, + AgentRunning, + ValidatingProgress, + SettlingTurn, + WaitingForApproval, + RetryBackoff, + Cancelling, + Recovering, + Finished, + #[default] + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxTaskIdentity { + pub item: LoopxIssueKey, + pub attempt: u32, + /// Issue / PR title captured at task creation, so task surfaces can show + /// content instead of only the item number. Empty for legacy records. + pub title: String, + /// Bounded plain-text excerpt of the issue/PR description captured at + /// task creation. Empty for legacy records. + pub description: String, + /// Remote item state observed at task creation. `Unknown` for legacy + /// records; the LoopX plan packet must never be built from a fabricated + /// open state when the adapter actually resolved a terminal state. + pub state: LoopxRemoteItemState, + /// Bounded label names observed at task creation. Empty for legacy + /// records; LoopX intake classification uses these as routing hints. + pub labels: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxSettlementSummary { + pub turn_id: Option, + pub receipt_id: Option, + pub durable_revision: Option, + pub settled_at: Option, +} + +/// Bounded read-only projection of the LoopX frontier todo that the current +/// turn plan selected. This is a UX snapshot only: the LoopX registry remains +/// the sole authority for todo lifecycle, and the host must not act on this +/// projection beyond display. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCurrentTodo { + pub todo_id: String, + pub task_class: String, + pub action_kind: String, + pub target_key: String, + pub claimed_by: String, + /// Authoritative LoopX due projection for monitor todos, kept as the raw + /// LoopX string (typically an ISO timestamp) so the host never fabricates + /// a parse result. + pub next_due_at: Option, + /// Bounded recommended-action text from the same LoopX envelope. + pub recommended_action: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxTaskSnapshot { + #[serde(alias = "id")] + pub task_id: String, + pub batch_id: Option, + pub identity: LoopxTaskIdentity, + pub generation: u64, + pub revision: u64, + pub goal_id: Option, + /// Read-only Goal lifecycle from LoopX. Legacy records leave this absent + /// until the host reconciles them against the CLI. + pub goal_state: Option, + pub agent_id: Option, + /// BitFun host-job lifecycle; this is not the Goal authority. + #[serde(alias = "status")] + pub state: LoopxTaskState, + pub phase: LoopxPhase, + /// Durable answerable gate projection. Event history may be truncated, so + /// interactive approval surfaces must not depend on replay to recover it. + pub pending_gate_id: Option, + pub pending_gate_message: Option, + pub pending_gate_action_kind: Option, + pub workspace_path: Option, + pub model_id: Option, + pub granted_scopes: Vec, + pub current_turn_id: Option, + pub current_tool: Option, + /// Last known LoopX frontier todo projection. Absent for legacy records + /// and cleared when the Goal reaches a terminal projection. + pub current_todo: Option, + pub last_output_at: Option, + /// Bounded final response from the latest Agent turn. It is persisted + /// before settlement so recovery surfaces retain the useful outcome even + /// when settlement verification fails. Empty for legacy tasks and active turns + /// that have not produced a final response yet. + pub last_agent_summary: Option, + pub last_agent_summary_at: Option, + /// Parsed `loopx_summary_v1` block extracted from the latest agent summary. + /// None for legacy tasks or when the agent produced no valid block; the UI + /// falls back to the raw text in that case. + pub structured_summary: Option, + /// Why the task currently needs recovery (host_restart, execution_failure, + /// settlement_unverified, plan_exhausted, repository_paused, manual_restore). + /// Absent for legacy records and for tasks that are not in RecoveryRequired. + pub recovery_reason: Option, + pub deadline_at: Option, + pub retry_at: Option, + pub error: Option, + pub settlement: LoopxSettlementSummary, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxExecutionDomain { + LocalDesktop, + RemoteWorkspace, + PeerDevice, + RemoteControl, + DetachedDispatch, + #[default] + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxExecutionSupport { + Supported, + #[default] + #[serde(other)] + UnsupportedExecutionDomain, +} + +fn default_contract_schema_version() -> u32 { + LOOPX_CLI_SCHEMA_VERSION +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxSnapshot { + #[serde(default = "default_contract_schema_version")] + pub schema_version: u32, + pub stream_id: String, + pub cursor: LoopxEventCursor, + pub revision: u64, + pub execution_domain: LoopxExecutionDomain, + pub execution_support: LoopxExecutionSupport, + pub unsupported_reason: Option, + pub environment: LoopxEnvironmentSnapshot, + pub tasks: Vec, + pub generated_at: i64, +} + +impl Default for LoopxSnapshot { + fn default() -> Self { + Self { + schema_version: default_contract_schema_version(), + stream_id: String::new(), + cursor: 0, + revision: 0, + execution_domain: LoopxExecutionDomain::default(), + execution_support: LoopxExecutionSupport::default(), + unsupported_reason: None, + environment: LoopxEnvironmentSnapshot::default(), + tasks: Vec::new(), + generated_at: 0, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxEventLevel { + Trace, + Debug, + Warning, + Error, + #[default] + #[serde(other)] + Info, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxEventSource { + #[default] + Controller, + Sidecar, + Agent, + Git, + Github, + System, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxEventKind { + #[default] + Progress, + TaskCreated, + StateChanged, + PhaseChanged, + Log, + ApprovalRequired, + SettlementRecorded, + EnvironmentChanged, + OperationCancelled, + SnapshotInvalidated, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxEvent { + pub stream_id: String, + pub cursor: LoopxEventCursor, + pub task_id: Option, + pub generation: Option, + pub revision: Option, + pub kind: LoopxEventKind, + pub level: LoopxEventLevel, + pub source: LoopxEventSource, + pub phase: Option, + pub message: String, + pub important: bool, + pub tool_name: Option, + pub deadline_at: Option, + pub details: BTreeMap, + pub occurred_at: i64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAttachRequest { + pub known_stream_id: Option, + pub after_cursor: Option, + /// Set only when the trusted MiniApp detects a wall-clock discontinuity + /// consistent with host suspend/resume. Legacy clients omit it. + pub resume_detected: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxAttachResponse { + pub snapshot: LoopxSnapshot, +} + +fn default_loopx_model_id() -> String { + "auto".to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxResolveIntakeRequest { + pub input: String, + #[serde(default = "default_loopx_model_id")] + pub model_id: String, +} + +impl Default for LoopxResolveIntakeRequest { + fn default() -> Self { + Self { + input: String::new(), + model_id: default_loopx_model_id(), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxResolveIntakeResponse { + pub preview: LoopxIntakePreview, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCreateTaskRequest { + pub client_request_id: String, + pub preview_fingerprint: String, + pub selected_items: Vec, + pub model_id: String, + pub granted_scopes: Vec, + pub retry_terminal: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxCreateTaskOutcomeKind { + #[default] + Created, + OpenedExisting, + RetryConfirmationRequired, + ClosedNoop, + NeedsLiveVerification, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCreateTaskOutcome { + pub item: LoopxIssueKey, + pub kind: LoopxCreateTaskOutcomeKind, + pub task_id: Option, + pub attempt: Option, + pub message: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxCreateTaskResponse { + pub outcomes: Vec, + pub snapshot_revision: u64, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxActionKind { + #[default] + Pause, + Abort, + Resume, + ResumeRepository, + ResetAll, + Approve, + Reject, + Archive, + Restore, + InstallLoopx, + RetryEnvironment, + #[serde(other)] + Unsupported, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxActionRequest { + pub task_id: Option, + pub repository: Option, + pub action: LoopxActionKind, + pub client_request_id: String, + pub expected_revision: u64, + pub gate_id: Option, + pub note: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxActionStatus { + #[default] + Applied, + Duplicate, + RevisionConflict, + Rejected, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxActionResponse { + pub status: LoopxActionStatus, + pub current_revision: u64, + pub task: Option, + pub message: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxEventsSinceRequest { + pub stream_id: String, + pub after_cursor: LoopxEventCursor, + pub limit: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxEventsPageStatus { + #[default] + Current, + SnapshotRequired, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxEventsSinceResponse { + pub status: LoopxEventsPageStatus, + pub stream_id: String, + pub events: Vec, + pub next_cursor: LoopxEventCursor, + pub has_more: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxTurnOutputStatus { + #[default] + Current, + TaskNotFound, + NotRunning, + StaleTurn, + OutputUnavailable, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopxTurnOutputEventKind { + #[default] + Text, + Thinking, + ModelRoundStarted, + ModelRoundCompleted, + Tool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxTurnOutputEvent { + pub cursor: LoopxEventCursor, + pub turn_id: String, + pub round_id: Option, + pub kind: LoopxTurnOutputEventKind, + pub text: Option, + pub tool_name: Option, + pub tool_state: Option, + pub is_end: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxTurnOutputSinceRequest { + pub task_id: String, + pub turn_id: Option, + pub stream_id: Option, + pub after_cursor: LoopxEventCursor, + pub limit: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxTurnOutputSinceResponse { + pub status: LoopxTurnOutputStatus, + pub task_id: String, + pub turn_id: Option, + pub stream_id: Option, + pub events: Vec, + pub next_cursor: LoopxEventCursor, + pub has_more: bool, + pub message: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopxExistingTask { + pub task_id: String, + pub identity: LoopxTaskIdentity, + pub state: LoopxTaskState, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_task_snapshot_defaults_agent_summary_fields() { + let task: LoopxTaskSnapshot = serde_json::from_value(serde_json::json!({ + "taskId": "legacy-task", + "state": "queued", + "phase": "queued" + })) + .expect("legacy task snapshot"); + + assert_eq!(task.last_agent_summary, None); + assert_eq!(task.last_agent_summary_at, None); + assert_eq!(task.pending_gate_id, None); + assert_eq!(task.pending_gate_message, None); + assert_eq!(task.pending_gate_action_kind, None); + } + + #[test] + fn legacy_attach_request_defaults_resume_signal() { + let request: LoopxAttachRequest = serde_json::from_value(serde_json::json!({ + "knownStreamId": "stream-1", + "afterCursor": 4 + })) + .expect("legacy attach request"); + + assert!(!request.resume_detected); + } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/mod.rs b/src/crates/contracts/product-domains/src/miniapp/mod.rs index bd079c5007..c6cf628075 100644 --- a/src/crates/contracts/product-domains/src/miniapp/mod.rs +++ b/src/crates/contracts/product-domains/src/miniapp/mod.rs @@ -10,6 +10,7 @@ pub mod draft; pub mod exporter; pub mod host_routing; pub mod lifecycle; +pub mod loopx; pub mod market; pub mod permission_policy; pub mod ports; diff --git a/src/crates/contracts/product-domains/src/miniapp/runtime.rs b/src/crates/contracts/product-domains/src/miniapp/runtime.rs index 5d1e127c1e..e8e6af9f8d 100644 --- a/src/crates/contracts/product-domains/src/miniapp/runtime.rs +++ b/src/crates/contracts/product-domains/src/miniapp/runtime.rs @@ -81,10 +81,37 @@ pub fn detect_runtime_with_probe( version, }); } + // Windows: the `which`-style PATH search treats any existing file + // without an extension as an executable candidate, so an npm-style + // extensionless shim (a POSIX script that CreateProcess cannot run) + // can beat the real launcher in the same directory (e.g. `bun` vs + // `bun.cmd`). Retry the same candidate with the standard executable + // suffixes before giving up on this runtime. + #[cfg(windows)] + { + for ext in [".exe", ".cmd", ".bat", ".com"] { + let candidate = append_suffix(&path, ext); + if let Some(version) = probe.runtime_version(&candidate) { + return Some(DetectedRuntime { + kind, + path: candidate, + version, + }); + } + } + } } None } +/// Append an extension suffix to a Windows candidate path, e.g. `bun` -> `bun.cmd`. +#[cfg(windows)] +fn append_suffix(path: &Path, ext: &str) -> PathBuf { + let mut os = path.as_os_str().to_owned(); + os.push(ext); + PathBuf::from(os) +} + pub fn runtime_lookup_order() -> &'static [&'static str] { &["bun", "node"] } @@ -294,4 +321,27 @@ mod tests { assert_eq!(detected.kind, RuntimeKind::Node); assert_eq!(detected.path, node); } + + #[cfg(windows)] + #[test] + fn detector_recovers_extensionless_shim_via_pathext_suffixes() { + // npm installs a POSIX `bun` shim (no extension, not executable by + // CreateProcess) alongside `bun.cmd`; the PATH search can return the + // shim first. The detector must retry the executable suffixes and + // still pick bun over node. + let mut probe = FakeProbe::default(); + let shim = PathBuf::from(r"C:\Program Files\bun\bun"); + let real = PathBuf::from(r"C:\Program Files\bun\bun.cmd"); + let node = PathBuf::from(r"C:\Program Files\nodejs\node.exe"); + probe.path_hits.insert("bun".to_string(), shim); + probe.path_hits.insert("node".to_string(), node.clone()); + probe.versions.insert(real.clone(), "1.3.14".to_string()); + probe.versions.insert(node, "v24.0.0".to_string()); + + let detected = detect_runtime_with_probe(&probe).expect("bun should be detected"); + + assert_eq!(detected.kind, RuntimeKind::Bun); + assert_eq!(detected.path, real); + assert_eq!(detected.version, "1.3.14"); + } } diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index f8736c3162..88a704dd73 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -461,6 +461,13 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("miniapp_host_call", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_import_from_path", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_install_deps", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), + op("miniapp_loopx_action", Unsupported, ControllerLocal, REFUSED), + op("miniapp_loopx_attach", Unsupported, ControllerLocal, REFUSED), + op("miniapp_loopx_create_task", Unsupported, ControllerLocal, REFUSED), + op("miniapp_loopx_events_since", Unsupported, ControllerLocal, REFUSED), + op("miniapp_loopx_list_models", Unsupported, ControllerLocal, REFUSED), + op("miniapp_loopx_resolve_intake", Unsupported, ControllerLocal, REFUSED), + op("miniapp_loopx_turn_output_since", Unsupported, ControllerLocal, REFUSED), op("miniapp_market_auth_poll", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_market_auth_start", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_market_browse", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), diff --git a/src/crates/contracts/product-domains/tests/loopx_contracts.rs b/src/crates/contracts/product-domains/tests/loopx_contracts.rs new file mode 100644 index 0000000000..23988f567f --- /dev/null +++ b/src/crates/contracts/product-domains/tests/loopx_contracts.rs @@ -0,0 +1,739 @@ +#![cfg(feature = "miniapp")] + +use openbitfun_product_domains::miniapp::loopx::{ + build_intake_fingerprint, decide_action_status, decide_events_page_status, + decide_repository_recovery_candidate, decide_task_dedup, decide_task_restart, + decide_task_transition, derive_environment_status, intake_scope_is_pregrantable, + is_loopx_monitor_action, parse_loopx_intake, parse_structured_summary, + project_host_task_from_goal, task_state_after_restart, task_summary_resolves_upstream, + LoopxActionKind, LoopxActionRequest, LoopxActionStatus, LoopxAgentFinishRequest, + LoopxAgentPort, LoopxAgentStartRequest, LoopxCliAnswerGateRequest, LoopxCliBuildTurnResult, + LoopxCliGateDecision, LoopxCliGoalSnapshot, LoopxCliGoalState, LoopxCliHandshakeRequest, + LoopxCliPort, LoopxCoreEnvironmentFacts, LoopxCreateTaskRequest, LoopxDedupDecision, + LoopxEnvironmentFact, LoopxEnvironmentFactStatus, LoopxEnvironmentStatus, + LoopxEventsPageStatus, LoopxExistingTask, LoopxIntakeCandidate, LoopxIntakeParseErrorKind, + LoopxIntakePreview, LoopxIntakeTarget, LoopxIssueKey, LoopxItemKind, + LoopxOptionalEnvironmentFacts, LoopxPermissionScope, LoopxPhase, LoopxRemoteItemState, + LoopxRepositoryKey, LoopxResolveIntakeRequest, LoopxRestartDecision, LoopxSnapshot, + LoopxTaskIdentity, LoopxTaskSnapshot, LoopxTaskState, LoopxTransitionDecision, + LoopxWorkspacePort, LoopxWorkspacePrepareRequest, LOOPX_CLI_SCHEMA_VERSION, + LOOPX_PINNED_VERSION, +}; + +fn issue(owner: &str, repository: &str, number: u64) -> LoopxIssueKey { + LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: owner.to_ascii_lowercase(), + repository: repository.to_ascii_lowercase(), + }, + kind: LoopxItemKind::Issue, + number, + } +} + +fn existing( + task_id: &str, + key: LoopxIssueKey, + attempt: u32, + state: LoopxTaskState, +) -> LoopxExistingTask { + LoopxExistingTask { + task_id: task_id.to_string(), + identity: LoopxTaskIdentity { + item: key, + attempt, + ..Default::default() + }, + state, + } +} + +#[test] +fn github_url_matrix_accepts_only_supported_intake_targets() { + let cases = [ + ( + "https://github.com/OpenAI/Codex/issues/123?utm_source=test#issuecomment-1", + "github.com/openai/codex/issues/123", + ), + ( + "https://www.github.com/OpenAI/Codex/pull/456/", + "github.com/openai/codex/pull/456", + ), + ("https://github.com/OpenAI/Codex", "github.com/openai/codex"), + ( + "https://github.com/OpenAI/Codex/issues?q=is%3Aopen", + "github.com/openai/codex", + ), + ("git@github.com:OpenAI/Codex.git", "github.com/openai/codex"), + ("OpenAI/Codex", "github.com/openai/codex"), + ]; + + for (input, expected) in cases { + let parsed = parse_loopx_intake(input).unwrap_or_else(|error| panic!("{input}: {error}")); + let actual = match parsed { + LoopxIntakeTarget::Repository { repository } => repository.canonical_id(), + LoopxIntakeTarget::Item { item } => item.canonical_id(), + }; + assert_eq!(actual, expected, "{input}"); + } + + let unsupported = parse_loopx_intake("https://gitlab.com/openai/codex/issues/1").unwrap_err(); + assert_eq!(unsupported.kind, LoopxIntakeParseErrorKind::UnsupportedHost); + let unsupported_path = + parse_loopx_intake("https://github.com/openai/codex/actions").unwrap_err(); + assert_eq!( + unsupported_path.kind, + LoopxIntakeParseErrorKind::UnsupportedPath + ); + let zero = parse_loopx_intake("https://github.com/openai/codex/issues/0").unwrap_err(); + assert_eq!(zero.kind, LoopxIntakeParseErrorKind::InvalidItemNumber); +} + +#[test] +fn canonical_item_identity_collapses_case_and_url_noise() { + let first = parse_loopx_intake("https://github.com/OpenAI/Codex/issues/42").unwrap(); + let second = parse_loopx_intake("http://github.com/openai/codex/issues/42?x=1").unwrap(); + assert_eq!(first, second); + + let LoopxIntakeTarget::Item { item } = first else { + panic!("expected item target"); + }; + assert_eq!(item.canonical_id(), "github.com/openai/codex/issues/42"); + assert_eq!( + item.canonical_url(), + "https://github.com/openai/codex/issues/42" + ); + + let pr = parse_loopx_intake("https://github.com/openai/codex/pull/42").unwrap(); + assert_ne!(LoopxIntakeTarget::Item { item }, pr); +} + +#[test] +fn nonterminal_duplicate_opens_the_existing_task() { + let key = issue("openai", "codex", 7); + let tasks = vec![existing( + "task-running", + key.clone(), + 1, + LoopxTaskState::Running, + )]; + + assert_eq!( + decide_task_dedup(&key, LoopxRemoteItemState::Open, &tasks, false), + LoopxDedupDecision::OpenExisting { + task_id: "task-running".to_string() + } + ); +} + +#[test] +fn terminal_duplicate_requires_an_explicit_new_attempt() { + let key = issue("openai", "codex", 8); + let tasks = vec![ + existing("attempt-1", key.clone(), 1, LoopxTaskState::Failed), + existing("attempt-2", key.clone(), 2, LoopxTaskState::Completed), + ]; + + assert_eq!( + decide_task_dedup(&key, LoopxRemoteItemState::Open, &tasks, false), + LoopxDedupDecision::RequireExplicitRetry { + previous_task_id: "attempt-2".to_string(), + next_attempt: 3, + } + ); + assert_eq!( + decide_task_dedup(&key, LoopxRemoteItemState::Open, &tasks, true), + LoopxDedupDecision::CreateAttempt { attempt: 3 } + ); +} + +#[test] +fn resolved_remote_item_is_a_successful_noop() { + let key = issue("openai", "codex", 9); + assert_eq!( + decide_task_dedup(&key, LoopxRemoteItemState::Closed, &[], false), + LoopxDedupDecision::ClosedNoop + ); + + let mut pr = key; + pr.kind = LoopxItemKind::PullRequest; + assert_eq!( + decide_task_dedup(&pr, LoopxRemoteItemState::Merged, &[], false), + LoopxDedupDecision::ClosedNoop + ); +} + +#[test] +fn restart_requeues_safe_pending_work_and_recovers_inflight_work() { + for state in [LoopxTaskState::Preparing, LoopxTaskState::RetryWait] { + assert_eq!( + decide_task_restart(state), + LoopxRestartDecision::Preserve { + state: LoopxTaskState::Queued + } + ); + assert_eq!(task_state_after_restart(state), LoopxTaskState::Queued); + } + + for state in [LoopxTaskState::Running, LoopxTaskState::Cancelling] { + assert_eq!( + decide_task_restart(state), + LoopxRestartDecision::RequireRecovery + ); + assert_eq!( + task_state_after_restart(state), + LoopxTaskState::RecoveryRequired + ); + } + + for state in [ + LoopxTaskState::Queued, + LoopxTaskState::WaitingForUser, + LoopxTaskState::RecoveryRequired, + LoopxTaskState::Stopped, + LoopxTaskState::Completed, + LoopxTaskState::Failed, + LoopxTaskState::Archived, + ] { + assert_eq!( + decide_task_restart(state), + LoopxRestartDecision::Preserve { state } + ); + assert_eq!(task_state_after_restart(state), state); + } +} + +#[test] +fn authoritative_goal_projection_preserves_explicit_host_stops() { + let stopped = project_host_task_from_goal( + LoopxTaskState::Stopped, + LoopxPhase::Finished, + LoopxCliGoalState::Completed, + ); + assert_eq!(stopped.state, LoopxTaskState::Stopped); + + let completed = project_host_task_from_goal( + LoopxTaskState::RecoveryRequired, + LoopxPhase::Recovering, + LoopxCliGoalState::Completed, + ); + assert_eq!(completed.state, LoopxTaskState::Completed); + assert_eq!(completed.phase, LoopxPhase::Finished); + + let reopened = project_host_task_from_goal( + LoopxTaskState::Completed, + LoopxPhase::Finished, + LoopxCliGoalState::Active, + ); + assert_eq!(reopened.state, LoopxTaskState::RecoveryRequired); + assert_eq!(reopened.phase, LoopxPhase::Recovering); + + let pending_approval = project_host_task_from_goal( + LoopxTaskState::WaitingForUser, + LoopxPhase::WaitingForApproval, + LoopxCliGoalState::Active, + ); + assert_eq!(pending_approval.state, LoopxTaskState::WaitingForUser); + assert_eq!(pending_approval.phase, LoopxPhase::WaitingForApproval); +} + +#[test] +fn transition_policy_separates_turn_completion_from_task_completion() { + assert_eq!( + decide_task_transition(LoopxTaskState::Running, LoopxTaskState::Queued), + LoopxTransitionDecision::Allowed { + next: LoopxTaskState::Queued + } + ); + assert_eq!( + decide_task_transition(LoopxTaskState::Running, LoopxTaskState::Completed), + LoopxTransitionDecision::Allowed { + next: LoopxTaskState::Completed + } + ); + assert_eq!( + decide_task_transition(LoopxTaskState::Completed, LoopxTaskState::Running), + LoopxTransitionDecision::Rejected + ); +} + +#[test] +fn additive_snapshot_fields_deserialize_with_safe_legacy_defaults() { + let snapshot: LoopxSnapshot = serde_json::from_value(serde_json::json!({ + "streamId": "legacy-stream", + "tasks": [{ + "id": "legacy-task", + "status": "legacy_active", + "identity": { + "item": { + "repository": { + "host": "github.com", + "owner": "openai", + "repository": "codex" + }, + "kind": "issue", + "number": 10 + }, + "attempt": 1 + } + }] + })) + .unwrap(); + + assert_eq!(snapshot.schema_version, LOOPX_CLI_SCHEMA_VERSION); + assert_eq!(snapshot.tasks[0].state, LoopxTaskState::RecoveryRequired); + assert_eq!(snapshot.tasks[0].task_id, "legacy-task"); + assert_eq!( + snapshot.environment.core.sidecar.status, + LoopxEnvironmentFactStatus::Unknown + ); + assert_eq!( + snapshot.execution_support.to_string(), + "unsupported_execution_domain" + ); + + let encoded = serde_json::to_value(&snapshot).unwrap(); + assert_eq!(encoded["tasks"][0]["state"], "recovery_required"); + assert_eq!(encoded["executionSupport"], "unsupported_execution_domain"); + + let preview: LoopxIntakePreview = serde_json::from_value(serde_json::json!({ + "fingerprint": "legacy-preview", + "workspace": { + "disposition": "clone_required", + "repositoryVerified": false + }, + "model": { + "modelId": "auto", + "available": true, + "supportsImages": false + } + })) + .unwrap(); + assert_eq!(preview.workspace.detail, None); + assert_eq!(preview.model.detail, None); + + let goal: LoopxCliGoalSnapshot = serde_json::from_value(serde_json::json!({ + "goalId": "legacy-goal", + "state": "waiting_for_user", + "runDecision": "waiting_for_user" + })) + .unwrap(); + assert_eq!(goal.pending_user_gate, None); +} + +#[test] +fn action_and_create_requests_use_idempotency_and_revision_fields() { + let resolve: LoopxResolveIntakeRequest = serde_json::from_value(serde_json::json!({ + "input": "https://github.com/openai/codex/issues/1" + })) + .unwrap(); + assert_eq!(resolve.model_id, "auto"); + + let action: LoopxActionRequest = serde_json::from_value(serde_json::json!({ + "taskId": "task-1", + "action": "retry_environment", + "clientRequestId": "request-1", + "expectedRevision": 17 + })) + .unwrap(); + assert_eq!(action.action, LoopxActionKind::RetryEnvironment); + assert_eq!(action.client_request_id, "request-1"); + assert_eq!(action.expected_revision, 17); + + let reset: LoopxActionRequest = serde_json::from_value(serde_json::json!({ + "action": "reset_all", + "clientRequestId": "request-reset", + "expectedRevision": 18 + })) + .unwrap(); + assert_eq!(reset.action, LoopxActionKind::ResetAll); + assert_eq!(reset.task_id, None); + + let install: LoopxActionRequest = serde_json::from_value(serde_json::json!({ + "action": "install_loopx", + "clientRequestId": "request-install", + "expectedRevision": 19 + })) + .unwrap(); + assert_eq!(install.action, LoopxActionKind::InstallLoopx); + assert_eq!(install.task_id, None); + + let retired_action: LoopxActionRequest = serde_json::from_value(serde_json::json!({ + "action": "install_open_viking", + "clientRequestId": "request-install-open-viking", + "expectedRevision": 20 + })) + .unwrap(); + assert_eq!(retired_action.action, LoopxActionKind::Unsupported); + assert_eq!(retired_action.task_id, None); + + let create: LoopxCreateTaskRequest = serde_json::from_value(serde_json::json!({ + "clientRequestId": "request-2", + "previewFingerprint": "sha256:abc", + "selectedItems": [], + "modelId": "primary", + "grantedScopes": ["workspace_read"], + "retryTerminal": false + })) + .unwrap(); + assert_eq!(create.client_request_id, "request-2"); + assert_eq!( + create.granted_scopes, + vec![LoopxPermissionScope::WorkspaceRead] + ); + + let page_status = serde_json::to_value(LoopxEventsPageStatus::SnapshotRequired).unwrap(); + assert_eq!(page_status, "snapshot_required"); +} + +#[test] +fn intake_fingerprint_is_order_independent_but_state_sensitive() { + let target = parse_loopx_intake("https://github.com/openai/codex/issues").unwrap(); + let candidate = |number, state| LoopxIntakeCandidate { + key: issue("openai", "codex", number), + url: format!("https://github.com/openai/codex/issues/{number}"), + title: format!("Issue {number}"), + state, + ..LoopxIntakeCandidate::default() + }; + let first = vec![ + candidate(1, LoopxRemoteItemState::Open), + candidate(2, LoopxRemoteItemState::Open), + ]; + let reversed = vec![first[1].clone(), first[0].clone()]; + let scopes = [ + LoopxPermissionScope::AgentExecution, + LoopxPermissionScope::WorkspaceWrite, + ]; + + let fingerprint = + build_intake_fingerprint(&target, &first, Some("/work/codex"), "primary", &scopes); + assert_eq!( + fingerprint, + build_intake_fingerprint(&target, &reversed, Some("/work/codex"), "primary", &scopes) + ); + + let changed = vec![ + candidate(1, LoopxRemoteItemState::Closed), + candidate(2, LoopxRemoteItemState::Open), + ]; + assert_ne!( + fingerprint, + build_intake_fingerprint(&target, &changed, Some("/work/codex"), "primary", &scopes) + ); +} + +#[test] +fn handshake_defaults_pin_the_supported_loopx_contract() { + let request: LoopxCliHandshakeRequest = serde_json::from_value(serde_json::json!({ + "operationId": "probe-1" + })) + .unwrap(); + assert_eq!(request.required_loopx_version, LOOPX_PINNED_VERSION); + assert_eq!(request.required_schema_version, LOOPX_CLI_SCHEMA_VERSION); + + fn assert_object_safe(_: &dyn LoopxCliPort) {} + let _ = assert_object_safe; +} + +#[test] +fn workspace_agent_and_gate_ports_keep_routes_typed() { + let key = issue("openai", "codex", 11); + let workspace = LoopxWorkspacePrepareRequest { + operation_id: "workspace-1".to_string(), + task_id: "task-1".to_string(), + item: key.clone(), + }; + let workspace_json = serde_json::to_value(workspace).unwrap(); + assert_eq!(workspace_json["operationId"], "workspace-1"); + assert_eq!(workspace_json["item"]["number"], 11); + assert!(workspace_json.get("worktreePath").is_none()); + + let agent = LoopxAgentStartRequest { + operation_id: "agent-1".to_string(), + task_id: "task-1".to_string(), + generation: 3, + worktree_path: "/worktrees/task-1".to_string(), + instruction: "Fix the selected issue".to_string(), + model_id: "primary".to_string(), + granted_scopes: LOOPX_REQUIRED_PERMISSION_SCOPES.to_vec(), + metadata: Default::default(), + }; + let agent_json = serde_json::to_value(agent).unwrap(); + assert_eq!(agent_json["generation"], 3); + assert_eq!(agent_json["worktreePath"], "/worktrees/task-1"); + assert_eq!(agent_json["instruction"], "Fix the selected issue"); + assert_eq!(agent_json["grantedScopes"].as_array().unwrap().len(), 5); + + let legacy_agent: LoopxAgentStartRequest = serde_json::from_value(serde_json::json!({ + "prompt": "Legacy LoopX prompt" + })) + .unwrap(); + assert_eq!(legacy_agent.instruction, "Legacy LoopX prompt"); + + let legacy_turn: LoopxCliBuildTurnResult = serde_json::from_value(serde_json::json!({ + "prompt": "Legacy turn prompt" + })) + .unwrap(); + assert_eq!(legacy_turn.agent_instruction, "Legacy turn prompt"); + + let finish = LoopxAgentFinishRequest { + operation_id: "finish-1".to_string(), + task_id: "task-1".to_string(), + generation: 3, + worktree_path: "/worktrees/task-1".to_string(), + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + }; + let finish_json = serde_json::to_value(finish).unwrap(); + assert_eq!(finish_json["worktreePath"], "/worktrees/task-1"); + assert_eq!(finish_json["sessionId"], "session-1"); + assert_eq!(finish_json["turnId"], "turn-1"); + let finish_roundtrip: LoopxAgentFinishRequest = serde_json::from_value(finish_json).unwrap(); + assert_eq!(finish_roundtrip.worktree_path, "/worktrees/task-1"); + assert_eq!(LoopxAgentFinishRequest::default().worktree_path, ""); + + let gate: LoopxCliAnswerGateRequest = serde_json::from_value(serde_json::json!({ + "operationId": "gate-1", + "taskId": "task-1", + "generation": 3, + "worktreePath": "/worktrees/task-1", + "registryPath": "/worktrees/task-1/.loopx/registry.json", + "goalId": "goal-1", + "agentId": "agent-1", + "gateId": "gate-publish", + "decision": "reject", + "note": "Do not publish", + "grantedScope": null + })) + .unwrap(); + assert_eq!(gate.decision, LoopxCliGateDecision::Reject); + assert_eq!(gate.agent_id, "agent-1"); + assert_eq!(gate.gate_id, "gate-publish"); + + fn assert_workspace_object_safe(_: &dyn LoopxWorkspacePort) {} + fn assert_agent_object_safe(_: &dyn LoopxAgentPort) {} + let _ = (assert_workspace_object_safe, assert_agent_object_safe); +} + +#[test] +fn optional_environment_failures_degrade_without_blocking_core_readiness() { + let available = LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Available, + ..LoopxEnvironmentFact::default() + }; + let core = LoopxCoreEnvironmentFacts { + sidecar: available.clone(), + git_worktree: available.clone(), + agent_model: available, + }; + let optional = LoopxOptionalEnvironmentFacts { + python_fallback: LoopxEnvironmentFact { + status: LoopxEnvironmentFactStatus::Unavailable, + ..LoopxEnvironmentFact::default() + }, + ..LoopxOptionalEnvironmentFacts::default() + }; + assert_eq!( + derive_environment_status(&core, &optional), + LoopxEnvironmentStatus::Degraded + ); + + let disabled_optional = LoopxOptionalEnvironmentFacts::default(); + assert_eq!( + derive_environment_status(&core, &disabled_optional), + LoopxEnvironmentStatus::Ready + ); + + let mut blocked_core = core; + blocked_core.sidecar.status = LoopxEnvironmentFactStatus::Unavailable; + assert_eq!( + derive_environment_status(&blocked_core, &optional), + LoopxEnvironmentStatus::Blocked + ); + assert!(intake_scope_is_pregrantable( + LoopxPermissionScope::WorkspaceWrite + )); + assert!(!intake_scope_is_pregrantable( + LoopxPermissionScope::PullRequest + )); +} + +#[test] +fn action_idempotency_precedes_revision_conflict() { + assert_eq!( + decide_action_status(true, 4, 5), + LoopxActionStatus::Duplicate + ); + assert_eq!( + decide_action_status(false, 4, 5), + LoopxActionStatus::RevisionConflict + ); + assert_eq!( + decide_action_status(false, 5, 5), + LoopxActionStatus::Applied + ); +} + +#[test] +fn event_cursor_gaps_and_stream_changes_require_a_snapshot() { + assert_eq!( + decide_events_page_status("stream-2", "stream-1", 10, Some(5), 20), + LoopxEventsPageStatus::SnapshotRequired + ); + assert_eq!( + decide_events_page_status("stream-2", "stream-2", 3, Some(5), 20), + LoopxEventsPageStatus::SnapshotRequired + ); + assert_eq!( + decide_events_page_status("stream-2", "stream-2", 4, Some(5), 20), + LoopxEventsPageStatus::Current + ); + assert_eq!( + decide_events_page_status("stream-2", "stream-2", 21, Some(5), 20), + LoopxEventsPageStatus::SnapshotRequired + ); +} + +trait EnumString { + fn to_string(self) -> String; +} + +impl EnumString for openbitfun_product_domains::miniapp::loopx::LoopxExecutionSupport { + fn to_string(self) -> String { + serde_json::to_value(self) + .unwrap() + .as_str() + .unwrap() + .to_string() + } +} + +#[test] +fn resolved_upstream_summaries_are_detected_in_both_locales() { + assert!(task_summary_resolves_upstream(Some( + "Issue covered-upstream by #618 (merged 2026-08-25); no follow-up required." + ))); + assert!(task_summary_resolves_upstream(Some( + "原始故障路径在 v2.0.3 中已消失,不开 PR,无需继续修复。" + ))); + assert!(!task_summary_resolves_upstream(Some( + "Candidate evidence collected; implementation requires parent write approval." + ))); + assert!(!task_summary_resolves_upstream(Some(" "))); + assert!(!task_summary_resolves_upstream(None)); +} + +#[test] +fn repository_recovery_candidates_exclude_resolved_upstream_tasks() { + let repository = "github.com/owner/repo"; + let mut task = LoopxTaskSnapshot { + task_id: "task-1".to_string(), + identity: LoopxTaskIdentity { + item: issue("owner", "repo", 515), + attempt: 1, + title: "t".to_string(), + description: String::new(), + state: LoopxRemoteItemState::Open, + labels: Vec::new(), + }, + state: LoopxTaskState::RecoveryRequired, + ..LoopxTaskSnapshot::default() + }; + assert!(decide_repository_recovery_candidate(&task, repository)); + + task.last_agent_summary = + Some("Issue covered_upstream by #618; no follow-up required.".to_string()); + assert!(!decide_repository_recovery_candidate(&task, repository)); + + task.state = LoopxTaskState::Running; + task.last_agent_summary = None; + assert!(!decide_repository_recovery_candidate(&task, repository)); + + task.state = LoopxTaskState::RecoveryRequired; + assert!(!decide_repository_recovery_candidate( + &task, + "github.com/owner/other" + )); +} + +#[test] +fn recovery_reason_survives_a_persist_round_trip() { + let mut task = LoopxTaskSnapshot { + task_id: "task-1".to_string(), + state: LoopxTaskState::RecoveryRequired, + recovery_reason: Some("settlement_unverified".to_string()), + ..LoopxTaskSnapshot::default() + }; + let json = serde_json::to_string(&task).expect("serialize"); + assert!(json.contains("recoveryReason")); + let parsed: LoopxTaskSnapshot = serde_json::from_str(&json).expect("deserialize"); + assert_eq!( + parsed.recovery_reason.as_deref(), + Some("settlement_unverified") + ); + + // Legacy payloads without the field deserialize with a None reason. + task.recovery_reason = None; + let legacy = serde_json::to_string(&LoopxTaskSnapshot { + task_id: "legacy".to_string(), + state: LoopxTaskState::Failed, + ..LoopxTaskSnapshot::default() + }) + .expect("serialize legacy"); + let parsed_legacy: LoopxTaskSnapshot = + serde_json::from_str(&legacy).expect("deserialize legacy"); + assert_eq!(parsed_legacy.recovery_reason, None); +} + +#[test] +fn structured_summary_parses_valid_block_and_rejects_contract_violations() { + let valid = "本段完成。\n\n```loopx_summary_v1\n{\"issue_verdict\":\"needs_fix\",\"reproduction\":\"reproduced\",\"reproduction_evidence\":\"evidence/rep.md\",\"segment_kind\":\"evidence\",\"completed\":[\"收集完成\"],\"next_step\":\"实现修复\"}\n```\n"; + let parsed = parse_structured_summary(Some(valid)).expect("valid block parses"); + assert_eq!( + parsed.get("issue_verdict").and_then(|v| v.as_str()), + Some("needs_fix") + ); + + // already_fixed_upstream without fixed_by is a contract violation. + let missing_link = "```loopx_summary_v1\n{\"issue_verdict\":\"already_fixed_upstream\"}\n```"; + assert_eq!(parse_structured_summary(Some(missing_link)), None); + + // wont_fix without a reason is rejected. + let missing_reason = "```loopx_summary_v1\n{\"issue_verdict\":\"wont_fix\"}\n```"; + assert_eq!(parse_structured_summary(Some(missing_reason)), None); + + // reproduced without evidence is rejected. + let missing_evidence = "```loopx_summary_v1\n{\"issue_verdict\":\"needs_fix\",\"reproduction\":\"reproduced\"}\n```"; + assert_eq!(parse_structured_summary(Some(missing_evidence)), None); + + // Unknown enum values are rejected. + let unknown = "```loopx_summary_v1\n{\"issue_verdict\":\"maybe\",\"fixed_by\":\"x\"}\n```"; + assert_eq!(parse_structured_summary(Some(unknown)), None); + + // No block at all falls back to None. + assert_eq!(parse_structured_summary(Some("plain text only")), None); + assert_eq!(parse_structured_summary(None), None); +} + +#[test] +fn monitor_action_classification_covers_track_and_monitor_kinds() { + // The pinned v0.5.1 issue-fix workflow emits the merge-readiness tracker + // as `issue_fix_track_pr_merge_readiness`; the older watch family uses the + // `_monitor` suffix. Both are monitor-class for the host compatibility + // cadence and the UI "PR monitor waiting" projection. + assert!(is_loopx_monitor_action( + "issue_fix_track_pr_merge_readiness" + )); + assert!(is_loopx_monitor_action("issue_fix_pr_state_open_monitor")); + assert!(is_loopx_monitor_action("continuous_monitor")); + // Real work segments never classify as monitor-class. + assert!(!is_loopx_monitor_action( + "issue_fix_collect_candidate_evidence" + )); + assert!(!is_loopx_monitor_action("issue_fix_reuse_existing_pr")); + assert!(!is_loopx_monitor_action("issue_fix_implementation")); + assert!(!is_loopx_monitor_action("")); + assert!(!is_loopx_monitor_action(" ")); +} diff --git a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs index 097d82507e..8b1b3bd453 100644 --- a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs @@ -591,10 +591,27 @@ fn miniapp_bridge_exposes_topic_session_lifecycle() { assert!(bridge.contains("agent.ensureSession")); assert!(bridge.contains("chat.focusSession")); assert!(bridge.contains("chat.clearSession")); + assert!(!bridge.contains("loopx.attach")); assert!(bridge.contains("_chatUserMessagePending")); assert!(bridge.contains("chat.completeUserMessage")); } +#[test] +fn miniapp_bridge_injects_loopx_only_for_the_builtin_identity() { + let ordinary = build_bridge_script("app-1", "/tmp/app", "/tmp/workspace", "dark", "win32"); + let builtin = build_bridge_script( + "builtin-bitfun-loopx", + "/tmp/app", + "/tmp/workspace", + "dark", + "win32", + ); + + assert!(!ordinary.contains("loopx.attach")); + assert!(builtin.contains("loopx.attach")); + assert!(builtin.contains("Private product extension")); +} + #[test] fn miniapp_permission_policy_preserves_scope_resolution() { let permissions = MiniAppPermissions { diff --git a/src/crates/execution/agent-stream/src/lib.rs b/src/crates/execution/agent-stream/src/lib.rs index 3c4bfd1846..8110a7c41b 100644 --- a/src/crates/execution/agent-stream/src/lib.rs +++ b/src/crates/execution/agent-stream/src/lib.rs @@ -696,6 +696,7 @@ impl StreamProcessor { let tool_event = if is_user_cancellation { ToolEventData::Cancelled { identity, + params: None, reason: reason.clone(), duration_ms: None, queue_wait_ms: None, @@ -705,6 +706,7 @@ impl StreamProcessor { } } else { ToolEventData::Failed { + params: None, identity, error: reason.clone(), duration_ms: None, diff --git a/src/crates/execution/tool-execution/src/pipeline.rs b/src/crates/execution/tool-execution/src/pipeline.rs index ec0eac62a3..4fe1a64f3f 100644 --- a/src/crates/execution/tool-execution/src/pipeline.rs +++ b/src/crates/execution/tool-execution/src/pipeline.rs @@ -100,6 +100,7 @@ pub enum ToolStateEventKind { chunks_received: usize, }, Completed { + params: Option, result: serde_json::Value, result_for_assistant: Option, image_attachments: Option>, @@ -110,6 +111,7 @@ pub enum ToolStateEventKind { execution_ms: Option, }, Failed { + params: Option, error: String, duration_ms: Option, queue_wait_ms: Option, @@ -119,6 +121,7 @@ pub enum ToolStateEventKind { }, Rejected, Cancelled { + params: Option, reason: String, duration_ms: Option, queue_wait_ms: Option, @@ -287,6 +290,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { chunks_received, }, ToolStateEventKind::Completed { + params, result, result_for_assistant, image_attachments, @@ -297,6 +301,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { execution_ms, } => ToolEventData::Completed { identity, + params, result: sanitize_tool_result_for_event(&result), result_for_assistant, image_attachments, @@ -307,6 +312,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { execution_ms, }, ToolStateEventKind::Failed { + params, error, duration_ms, queue_wait_ms, @@ -315,6 +321,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { execution_ms, } => ToolEventData::Failed { identity, + params, error, duration_ms, queue_wait_ms, @@ -324,6 +331,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { }, ToolStateEventKind::Rejected => ToolEventData::Rejected { identity }, ToolStateEventKind::Cancelled { + params, reason, duration_ms, queue_wait_ms, @@ -332,6 +340,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { execution_ms, } => ToolEventData::Cancelled { identity, + params, reason, duration_ms, queue_wait_ms, @@ -374,6 +383,7 @@ mod tests { let data = tool_state_event_data(ToolStateEventFacts { identity: ToolEventIdentity::direct("tool-image-1", "view_image"), state: ToolStateEventKind::Completed { + params: None, result: json!({ "path": "preview.png" }), result_for_assistant: Some("Image attached".to_string()), image_attachments: Some(vec![openbitfun_events::ToolImageAttachment { @@ -404,6 +414,7 @@ mod tests { let data = tool_state_event_data(ToolStateEventFacts { identity: ToolEventIdentity::direct("tool-1", "Screenshot"), state: ToolStateEventKind::Completed { + params: None, result: json!({ "data_url": "data:image/png;base64,AAAA", "nested": [{ "data_url": "data:image/png;base64,BBBB" }] diff --git a/src/crates/interfaces/acp/src/client/stream.rs b/src/crates/interfaces/acp/src/client/stream.rs index cf5bf04a2f..4b8bce90e2 100644 --- a/src/crates/interfaces/acp/src/client/stream.rs +++ b/src/crates/interfaces/acp/src/client/stream.rs @@ -271,6 +271,7 @@ fn acp_tool_call_events( Some(tool_call.locations), ); events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { + params: None, identity: openbitfun_events::ToolEventIdentity::direct(tool_id, tool_name), result, result_for_assistant: None, @@ -284,6 +285,7 @@ fn acp_tool_call_events( } ToolCallStatus::Failed => { events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Failed { + params: None, identity: openbitfun_events::ToolEventIdentity::direct(tool_id, tool_name), error: acp_tool_error_text(tool_call.raw_output, tool_call.content), duration_ms: None, @@ -316,13 +318,17 @@ fn acp_tool_call_update_events( match update.fields.status { Some(ToolCallStatus::Completed) => { let mut events = Vec::new(); - if let Some(raw_input) = snapshot.raw_input { + let input_params = snapshot + .raw_input + .as_ref() + .map(|raw| normalize_tool_params(&tool_name, materialize_raw_input(raw.clone()))); + if let Some(params) = input_params.clone() { events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Started { identity: openbitfun_events::ToolEventIdentity::direct( tool_id.clone(), tool_name.clone(), ), - params: normalize_tool_params(&tool_name, materialize_raw_input(raw_input)), + params, timeout_seconds: None, })); } @@ -333,6 +339,7 @@ fn acp_tool_call_update_events( update.fields.locations, ); events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { + params: input_params, identity: openbitfun_events::ToolEventIdentity::direct(tool_id, tool_name), result, result_for_assistant: None, @@ -358,6 +365,7 @@ fn acp_tool_call_update_events( })); } events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Failed { + params: None, identity: openbitfun_events::ToolEventIdentity::direct(tool_id, tool_name), error: acp_tool_error_text( update.fields.raw_output, diff --git a/src/crates/interfaces/acp/src/runtime/events.rs b/src/crates/interfaces/acp/src/runtime/events.rs index 4f93163401..a768ba85bd 100644 --- a/src/crates/interfaces/acp/src/runtime/events.rs +++ b/src/crates/interfaces/acp/src/runtime/events.rs @@ -566,6 +566,7 @@ mod tests { fn completed_event_maps_to_completed_update_with_output() { let mut seen = HashSet::new(); let event = ToolEventData::Completed { + params: None, identity: identity("ExecCommand"), result: serde_json::json!({ "stdout": "ok" }), result_for_assistant: Some("done".to_string()), @@ -876,6 +877,7 @@ mod tests { let old_string = "old".repeat(ACP_LARGE_TEXT_PREVIEW_CHARS); let new_string = "new".repeat(ACP_LARGE_TEXT_PREVIEW_CHARS); let event = ToolEventData::Completed { + params: None, identity: identity("Edit"), result: serde_json::json!({ "file_path": "src/lib.rs", diff --git a/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs b/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs index 8b5da9f176..0019308fa8 100644 --- a/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs +++ b/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs @@ -513,6 +513,7 @@ impl AgentDialogTurnPort for FakeOwner { attempt_id: Some("attempt-fixture".to_string()), attempt_index: Some(0), tool_event: ToolEventData::Completed { + params: None, identity: ToolEventIdentity::direct("tool-fixture", "Read"), result: serde_json::json!({ "content": "must-not-leak" }), result_for_assistant: None, diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 16865ed8f5..afceda5f5f 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -72,6 +72,22 @@ slices that are outside pure product logic but still platform-neutral. writes, marker IO, storage/import bundle filesystem IO, and JS worker process/pool lifecycle. Manager workflow orchestration remains outside this crate until reviewed owner migration. +- LoopX v0.5.1 `turn plan --include-transaction-detail` is a prospective plan, + not a completed-receipt query. External-host settlement must verify the + exact goal/agent/Todo/turn identity through the supported compact `history` + projection and require both typed `validated_progress` and the matching + `quota_slot_spent` event. A legacy `validated_progress` without + `progress_observation` is accountable only when it carries an explicit + progress delivery outcome. After exact validated progress, the host may + idempotently repair a missing turn-scoped quota spend and must re-read + history before accepting settlement. Do not infer success from the next turn + plan or fabricate progress when no matching validation exists. +- WebFetch treats HTTP 401, 403, and 429 as structured access restrictions so + the Agent can change routes without presenting an expected server policy as + a tool crash. Preserve retry/rate-limit headers, prohibit blind same-URL + retries in the result guidance, and route authentication through an existing + browser or provider boundary; never bypass access controls in the HTTP + provider. - Managed plugin source integration may own bounded package discovery, integrity checks, fixed package input reads, no-follow path handling, trust-file locking, and atomic persistence. Product path selection stays in diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 372f5efef5..9c1c6985ca 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -68,6 +68,7 @@ url = { workspace = true, optional = true } which = { workspace = true, optional = true } zip = { workspace = true, optional = true } dirs = { workspace = true, optional = true } +encoding_rs = { workspace = true, optional = true } russh = { workspace = true, optional = true } russh-sftp = { workspace = true, optional = true } russh-keys = { workspace = true, optional = true } @@ -191,6 +192,32 @@ miniapp-runtime = [ "uuid", "which", ] +miniapp-loopx = [ + "async-trait", + "openbitfun-product-domains/miniapp", + "openbitfun-services-core/process-runtime", + "openbitfun-services-core/tls-provider", + "dep:openbitfun-product-domains", + "dunce", + "dep:encoding_rs", + "hex", + "reqwest", + "reqwest/json", + "reqwest/rustls-no-provider", + "sha2", + "thiserror", + "tokio/fs", + "tokio/io-util", + "tokio/macros", + "tokio/process", + "tokio/rt", + "tokio/sync", + "tokio/time", + "tokio-util", + "url", + "uuid", + "which", +] miniapp-market = [ "openbitfun-services-core/tls-provider", "openbitfun-product-domains/appearance-market", @@ -442,6 +469,7 @@ product-full = [ "function-agents", "git", "hook-import", + "miniapp-loopx", "miniapp-runtime", "mcp", "plugin-source", @@ -494,6 +522,11 @@ name = "mcp_streamable_http_contracts" path = "tests/mcp_streamable_http_contracts.rs" required-features = ["mcp"] +[[test]] +name = "miniapp_loopx_contracts" +path = "tests/miniapp_loopx_contracts.rs" +required-features = ["miniapp-loopx"] + [[test]] name = "remote_connect_contracts" path = "tests/remote_connect_contracts.rs" diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index ef0bfd48f8..6cb6a3dfea 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -66,6 +66,9 @@ pub mod models_dev; #[cfg(feature = "miniapp-runtime")] pub mod miniapp; +#[cfg(all(feature = "miniapp-loopx", not(feature = "miniapp-runtime")))] +pub mod miniapp; + #[cfg(feature = "miniapp-market")] pub mod miniapp_market; diff --git a/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs b/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs index 03d7c3fb90..e97f79b220 100644 --- a/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs +++ b/src/crates/services/services-integrations/src/miniapp/host_dispatch.rs @@ -4,7 +4,7 @@ //! Why this exists //! --------------- //! The original MiniApp design routed every `app.*` call through a Bun/Node Worker -//! (`resources/worker_host.js`). That gives apps a real V8 sandbox for arbitrary +//! (`resources/worker_host.cjs`). That gives apps a real V8 sandbox for arbitrary //! `worker.js` code, but it forces every app — even ones that just want to shell out //! to `git` — to depend on having Bun or Node installed and a worker runtime online. //! @@ -19,7 +19,7 @@ //! pool when the app has `node.enabled = true`. `storage.*` is served by the manager //! directly from the Tauri command layer regardless of node.enabled. //! -//! Permission enforcement here mirrors `worker_host.js` exactly so the security +//! Permission enforcement here mirrors `worker_host.cjs` exactly so the security //! contract is identical regardless of the routing path. use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; @@ -105,7 +105,7 @@ pub type MiniAppHostDispatchResult = Result; /// Dispatch a framework-primitive RPC on the host. /// /// `perms` and the path arguments are used to build a permission policy with the -/// same shape `worker_host.js` consumes, then the namespace-specific handler is +/// same shape `worker_host.cjs` consumes, then the namespace-specific handler is /// invoked. pub async fn dispatch_host( perms: &MiniAppPermissions, @@ -169,7 +169,7 @@ fn canonicalize_best_effort(p: &Path) -> PathBuf { } /// A target path is allowed when its canonicalized form starts with one of the -/// canonicalized scope roots. Mirrors the worker_host.js check, but uses real +/// canonicalized scope roots. Mirrors the worker_host.cjs check, but uses real /// canonicalization so e.g. `/tmp/foo` on macOS (`/private/tmp/foo`) matches a /// `/tmp` scope after both sides resolve symlinks. fn path_allowed(policy: &Value, target: &Path, mode: FsAccessMode) -> bool { @@ -410,7 +410,7 @@ async fn dispatch_shell( } }; cmd.current_dir(&plan.cwd); - // Match worker_host.js: never let git prompt for credentials, force C locale so + // Match worker_host.cjs: never let git prompt for credentials, force C locale so // stdout parsing is deterministic. for (key, value) in shell_exec_default_env() { cmd.env(key, value); @@ -433,7 +433,7 @@ async fn dispatch_shell( let code = output.status.code().unwrap_or(-1); if !output.status.success() { - // Mirror worker_host.js (which uses Node `execAsync`, rejecting on non-zero + // Mirror worker_host.cjs (which uses Node `execAsync`, rejecting on non-zero // exit with stderr in the message). let msg = if !stderr.trim().is_empty() { stderr.trim().to_string() diff --git a/src/crates/services/services-integrations/src/miniapp/loopx_cli.rs b/src/crates/services/services-integrations/src/miniapp/loopx_cli.rs new file mode 100644 index 0000000000..32bbd547a6 --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp/loopx_cli.rs @@ -0,0 +1,4756 @@ +//! Managed LoopX CLI integration for the built-in LoopX MiniApp. +//! +//! The product-facing adapter below never accepts an executable or an argv +//! prefix from callers. It selects the packaged binary first and only permits +//! the fixed `loopx` system command when that fallback was explicitly enabled. + +use async_trait::async_trait; +use openbitfun_product_domains::miniapp::loopx as loopx_contract; +use openbitfun_services_core::process_tree::ProcessTreeChild; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use thiserror::Error; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::Command; +use tokio::sync::{mpsc, Mutex}; +use tokio_util::sync::CancellationToken; + +pub const LOOPX_PINNED_VERSION: &str = "0.5.1"; +pub const LOOPX_PINNED_VERSION_TAG: &str = "v0.5.1"; +pub const LOOPX_PINNED_VERSION_OUTPUT: &str = "loopx 0.5.1"; +pub const LOOPX_SOURCE_REPOSITORY: &str = "https://github.com/huangruiteng/loopx.git"; +pub const LOOPX_PINNED_SOURCE_COMMIT: &str = "1bb42f4cb3e329dcb71c64654228f951098cead1"; +pub const LOOPX_BUNDLE_MANIFEST_SCHEMA: u32 = 1; +pub const LOOPX_COMMAND_REFERENCE_SCHEMA: &str = "loopx_command_reference_v0"; + +const MAX_STDOUT_BYTES: usize = 8 * 1024 * 1024; +const MAX_STDERR_TAIL_BYTES: usize = 32 * 1024; +const MAX_PROGRESS_LINE_BYTES: usize = 4 * 1024; +const PIPE_DRAIN_DEADLINE: Duration = Duration::from_millis(250); +const SETTLEMENT_HISTORY_LIMIT: &str = "100"; +const MANAGED_SOURCE_MANIFEST: &str = ".bitfun-managed-source.json"; +const MANAGED_SOURCE_MANIFEST_SCHEMA: u32 = 1; +const PYTHON_LOOPX_ENTRYPOINT: &str = "import os,sys; sys.path.insert(0, os.environ['BITFUN_LOOPX_SOURCE']); from loopx.entrypoint import main; raise SystemExit(main())"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoopxSystemFallbackPolicy { + Disabled, + ExactPinned, +} + +#[derive(Debug, Clone)] +pub struct LoopxCliAdapterConfig { + pub resource_dir: PathBuf, + pub managed_source_dir: Option, + pub system_fallback: LoopxSystemFallbackPolicy, + pub startup_deadline: Duration, + pub command_deadline: Duration, + pub install_deadline: Duration, + pub terminate_grace: Duration, +} + +impl LoopxCliAdapterConfig { + pub fn packaged(resource_dir: impl Into) -> Self { + Self { + resource_dir: resource_dir.into(), + managed_source_dir: None, + system_fallback: LoopxSystemFallbackPolicy::Disabled, + startup_deadline: Duration::from_secs(60), + command_deadline: Duration::from_secs(180), + install_deadline: Duration::from_secs(10 * 60), + terminate_grace: Duration::from_secs(2), + } + } + + pub fn with_managed_source_dir(mut self, managed_source_dir: impl Into) -> Self { + self.managed_source_dir = Some(managed_source_dir.into()); + self + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoopxCommandSource { + PackagedBundle, + ManagedSource, + FixedSystemCommand, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedLoopxCommand { + pub executable: PathBuf, + pub prefix_args: Vec, + pub environment: BTreeMap, + pub source: LoopxCommandSource, + pub version: String, + pub bundle_manifest_schema: Option, + pub command_reference_schema: String, + pub sha256: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoopxCommandPlan { + pub operation_id: String, + pub executable: PathBuf, + pub args: Vec, + pub current_dir: Option, + pub environment: BTreeMap, + pub deadline: Duration, + pub terminate_grace: Duration, +} + +impl LoopxCommandPlan { + fn handshake( + operation_id: impl Into, + executable: PathBuf, + prefix_args: &[OsString], + environment: &BTreeMap, + args: impl IntoIterator>, + deadline: Duration, + terminate_grace: Duration, + ) -> Self { + let mut command_args = prefix_args.to_vec(); + command_args.extend(args.into_iter().map(Into::into)); + Self { + operation_id: operation_id.into(), + executable, + args: command_args, + current_dir: None, + environment: environment.clone(), + deadline, + terminate_grace, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoopxProgressStage { + Starting, + Stderr, + Exited, + Cancelling, + TimedOut, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoopxProcessProgress { + pub operation_id: String, + pub stage: LoopxProgressStage, + pub message: String, + pub occurred_at_unix_ms: u64, +} + +pub trait LoopxProcessObserver: Send + Sync { + fn on_progress(&self, progress: LoopxProcessProgress); +} + +#[derive(Debug, Default)] +pub struct NoopLoopxProcessObserver; + +impl LoopxProcessObserver for NoopLoopxProcessObserver { + fn on_progress(&self, _progress: LoopxProcessProgress) {} +} + +/// Forces UTF-8 stdio for the packaged LoopX sidecar. +/// +/// The bundled `loopx.exe` is a PyInstaller-built Python CLI. On Windows hosts +/// whose ANSI code page is not UTF-8 (e.g. zh-CN / cp936), Python's stdio +/// encoding for a piped child defaults to the locale code page, so JSON output +/// containing non-ASCII text is emitted as GBK bytes. The host captures stdout +/// as UTF-8 (lossy), which replaces those bytes with U+FFFD and persists +/// mojibake in gate messages and readbacks. `PYTHONUTF8` / `PYTHONIOENCODING` +/// make the CPython runtime use UTF-8 for stdio regardless of the host code +/// page, keeping the durable readback and gate messages intact. +fn with_utf8_stdio(mut environment: BTreeMap) -> BTreeMap { + environment.insert(OsString::from("PYTHONUTF8"), OsString::from("1")); + environment.insert(OsString::from("PYTHONIOENCODING"), OsString::from("utf-8")); + environment +} + +/// Decodes LoopX process output as UTF-8 with a GBK fallback. +/// +/// The packaged `loopx.exe` is a PyInstaller bundle whose Python runtime keeps +/// its stdio on the host ANSI code page (cp936 on zh-CN hosts) even when +/// `PYTHONUTF8=1` / `PYTHONIOENCODING=utf-8` are set; the managed-source +/// Python entrypoint does honor those variables. Decoding the bundle's GBK +/// bytes as UTF-8 lossily turns every non-ASCII character into U+FFFD, which +/// then lands in gate messages, todo text and readbacks. Try strict UTF-8 +/// first, fall back to GBK, and only then apply the lossy decode. +fn decode_loopx_output(bytes: &[u8]) -> String { + if let Ok(text) = std::str::from_utf8(bytes) { + return text.to_string(); + } + let (decoded, _, _) = encoding_rs::GBK.decode(bytes); + decoded.into_owned() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoopxProcessOutput { + pub stdout: String, + pub stderr_tail: Vec, + pub elapsed: Duration, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum LoopxProcessError { + #[error("failed to start LoopX process: {message}")] + Start { message: String }, + #[error("LoopX process IO failed: {message}")] + Io { message: String }, + #[error("LoopX process exited with status {code:?}")] + Exited { + code: Option, + stdout_tail: Vec, + stderr_tail: Vec, + /// Full JSON payload the CLI printed on stdout before the non-zero + /// exit, when it parses. The pinned CLI renders typed `ok:false` + /// failures as a complete payload followed by exit 1 (for example the + /// `RunNow`-without-todo replan frontier, whose host-bound route + /// lineage check fails); callers can salvage that projection instead + /// of failing on the raw process exit. `None` when stdout was not a + /// single JSON document. + payload: Option, + }, + #[error("LoopX process timed out after {deadline_ms} ms")] + Timeout { + deadline_ms: u64, + stderr_tail: Vec, + }, + #[error("LoopX process was cancelled")] + Cancelled { stderr_tail: Vec }, + #[error("LoopX stdout exceeded the {limit_bytes}-byte limit")] + OutputLimit { limit_bytes: usize }, +} + +#[async_trait] +pub trait LoopxProcessRunner: Send + Sync { + async fn run( + &self, + plan: LoopxCommandPlan, + cancellation: CancellationToken, + observer: &dyn LoopxProcessObserver, + ) -> Result; +} + +#[derive(Debug, Default)] +pub struct SystemLoopxProcessRunner; + +#[async_trait] +impl LoopxProcessRunner for SystemLoopxProcessRunner { + async fn run( + &self, + plan: LoopxCommandPlan, + cancellation: CancellationToken, + observer: &dyn LoopxProcessObserver, + ) -> Result { + emit_progress( + observer, + &plan.operation_id, + LoopxProgressStage::Starting, + "Starting LoopX process", + ); + + let started = Instant::now(); + let mut command = Command::new(&plan.executable); + command + .args(&plan.args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(current_dir) = &plan.current_dir { + command.current_dir(current_dir); + } + if !plan.environment.is_empty() { + command.envs(&plan.environment); + } + + let mut child = ProcessTreeChild::spawn(&mut command) + .await + .map_err(|error| LoopxProcessError::Start { + message: error.to_string(), + })?; + let stdout = child.take_stdout().ok_or_else(|| LoopxProcessError::Io { + message: "LoopX stdout pipe was not available".to_string(), + })?; + let stderr = child.take_stderr().ok_or_else(|| LoopxProcessError::Io { + message: "LoopX stderr pipe was not available".to_string(), + })?; + + let mut stdout_task = tokio::spawn(capture_stdout(stdout)); + let (stderr_line_tx, mut stderr_line_rx) = mpsc::channel(128); + let mut stderr_task = + tokio::spawn(async move { capture_stderr(stderr, stderr_line_tx).await }); + + enum Completion { + Exited(std::io::Result), + Cancelled, + TimedOut, + } + + let deadline = tokio::time::sleep(plan.deadline); + tokio::pin!(deadline); + let completion = loop { + tokio::select! { + biased; + _ = cancellation.cancelled() => break Completion::Cancelled, + _ = &mut deadline => break Completion::TimedOut, + status = child.wait() => break Completion::Exited(status), + line = stderr_line_rx.recv() => { + if let Some(line) = line { + emit_progress( + observer, + &plan.operation_id, + LoopxProgressStage::Stderr, + &line, + ); + } + } + } + }; + while let Ok(line) = stderr_line_rx.try_recv() { + emit_progress( + observer, + &plan.operation_id, + LoopxProgressStage::Stderr, + &line, + ); + } + + match completion { + Completion::Cancelled => { + emit_progress( + observer, + &plan.operation_id, + LoopxProgressStage::Cancelling, + "Cancelling LoopX process tree", + ); + let _ = child.terminate(plan.terminate_grace).await; + let stderr_tail = drain_stderr_task(&mut stderr_task).await; + stdout_task.abort(); + Err(LoopxProcessError::Cancelled { stderr_tail }) + } + Completion::TimedOut => { + emit_progress( + observer, + &plan.operation_id, + LoopxProgressStage::TimedOut, + "LoopX process deadline expired", + ); + let _ = child.terminate(plan.terminate_grace).await; + let stderr_tail = drain_stderr_task(&mut stderr_task).await; + stdout_task.abort(); + Err(LoopxProcessError::Timeout { + deadline_ms: duration_millis(plan.deadline), + stderr_tail, + }) + } + Completion::Exited(status) => { + let status = status.map_err(|error| LoopxProcessError::Io { + message: error.to_string(), + })?; + let stdout_capture = drain_stdout_task(&mut stdout_task).await?; + let stderr_tail = drain_stderr_task(&mut stderr_task).await; + emit_progress( + observer, + &plan.operation_id, + LoopxProgressStage::Exited, + if status.success() { + "LoopX process exited successfully" + } else { + "LoopX process exited with an error" + }, + ); + if !status.success() { + return Err(LoopxProcessError::Exited { + code: status.code(), + stdout_tail: output_tail(&stdout_capture.bytes), + stderr_tail, + payload: serde_json::from_str(&decode_loopx_output(&stdout_capture.bytes)).ok(), + }); + } + if stdout_capture.exceeded_limit { + return Err(LoopxProcessError::OutputLimit { + limit_bytes: MAX_STDOUT_BYTES, + }); + } + Ok(LoopxProcessOutput { + stdout: decode_loopx_output(&stdout_capture.bytes), + stderr_tail, + elapsed: started.elapsed(), + }) + } + } + } +} + +pub trait LoopxFixedCommandLocator: Send + Sync { + fn locate(&self) -> Result, String>; +} + +pub trait LoopxPythonLocator: Send + Sync { + fn locate(&self) -> Result, String>; +} + +#[async_trait] +pub trait LoopxIntakeMetadataProvider: Send + Sync { + async fn resolve( + &self, + request: &loopx_contract::LoopxCliResolveIntakeRequest, + deadline: Duration, + ) -> loopx_contract::LoopxCliResult; + + /// Pre-flight GitHub access probe used to populate the `github_auth` + /// environment fact before any intake is submitted. + async fn probe_auth( + &self, + deadline: Duration, + ) -> loopx_contract::LoopxCliResult; + + /// Whether the authenticated GitHub identity can merge pull requests in + /// the repository. `Ok(None)` = unknown (no credential or probe failure); + /// callers must fail open to interactive decisions. + async fn viewer_merge_authority( + &self, + _repository: &loopx_contract::LoopxRepositoryKey, + _deadline: Duration, + ) -> loopx_contract::LoopxCliResult> { + Ok(None) + } +} + +#[derive(Debug, Default)] +pub struct UnsupportedLoopxIntakeMetadataProvider; + +#[async_trait] +impl LoopxIntakeMetadataProvider for UnsupportedLoopxIntakeMetadataProvider { + async fn resolve( + &self, + request: &loopx_contract::LoopxCliResolveIntakeRequest, + _deadline: Duration, + ) -> loopx_contract::LoopxCliResult { + Err(loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::NotFound, + "LoopX intake metadata provider is not configured", + ) + .for_operation(&request.call.operation_id) + .retryable(true)) + } + + async fn probe_auth( + &self, + _deadline: Duration, + ) -> loopx_contract::LoopxCliResult { + Ok(loopx_contract::LoopxGithubAuthProbe { + authenticated: false, + detail: Some("GitHub intake metadata provider is not configured".to_string()), + ..loopx_contract::LoopxGithubAuthProbe::default() + }) + } +} + +#[derive(Debug, Default)] +pub struct SystemLoopxFixedCommandLocator; + +impl LoopxFixedCommandLocator for SystemLoopxFixedCommandLocator { + fn locate(&self) -> Result, String> { + match which::which("loopx") { + Ok(path) => Ok(Some(path)), + Err(which::Error::CannotFindBinaryPath) => Ok(None), + Err(error) => Err(error.to_string()), + } + } +} + +#[derive(Debug, Default)] +pub struct SystemLoopxPythonLocator; + +impl LoopxPythonLocator for SystemLoopxPythonLocator { + fn locate(&self) -> Result, String> { + let candidates = if cfg!(windows) { + ["python", "python3"] + } else { + ["python3", "python"] + }; + for candidate in candidates { + match which::which(candidate) { + Ok(path) => return Ok(Some(path)), + Err(which::Error::CannotFindBinaryPath) => continue, + Err(error) => return Err(error.to_string()), + } + } + Ok(None) + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum LoopxCliAdapterError { + #[error("compatible LoopX runtime is not available")] + Unavailable, + #[error("invalid packaged LoopX manifest: {message}")] + Manifest { message: String }, + #[error("LoopX version mismatch: expected {expected}, got {actual}")] + VersionMismatch { expected: String, actual: String }, + #[error("LoopX schema mismatch: expected {expected}, got {actual}")] + SchemaMismatch { expected: String, actual: String }, + #[error("LoopX operation id is already running: {operation_id}")] + Conflict { operation_id: String }, + #[error("LoopX returned invalid JSON: {message}")] + InvalidJson { message: String }, + #[error(transparent)] + Process(#[from] LoopxProcessError), +} + +#[derive(Debug, Clone)] +pub struct LoopxJsonOutput { + pub payload: Value, + pub stderr_tail: Vec, + pub elapsed: Duration, +} + +pub struct LoopxCliProcessAdapter { + config: LoopxCliAdapterConfig, + runner: Arc, + locator: Arc, + python_locator: Arc, + observer: Arc, + intake_metadata: Arc, + intake_metadata_configured: bool, + install_lock: Mutex<()>, + verified: Mutex>, + running: Arc>>, +} + +impl std::fmt::Debug for LoopxCliProcessAdapter { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("LoopxCliProcessAdapter") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +impl LoopxCliProcessAdapter { + pub fn new(config: LoopxCliAdapterConfig) -> Self { + Self::with_dependencies( + config, + Arc::new(SystemLoopxProcessRunner), + Arc::new(SystemLoopxFixedCommandLocator), + Arc::new(SystemLoopxPythonLocator), + Arc::new(NoopLoopxProcessObserver), + ) + } + + pub fn with_dependencies( + config: LoopxCliAdapterConfig, + runner: Arc, + locator: Arc, + python_locator: Arc, + observer: Arc, + ) -> Self { + Self { + config, + runner, + locator, + python_locator, + observer, + intake_metadata: Arc::new(UnsupportedLoopxIntakeMetadataProvider), + intake_metadata_configured: false, + install_lock: Mutex::new(()), + verified: Mutex::new(None), + running: Arc::new(StdMutex::new(HashMap::new())), + } + } + + pub fn with_intake_metadata_provider( + mut self, + provider: Arc, + ) -> Self { + self.intake_metadata = provider; + self.intake_metadata_configured = true; + self + } + + pub async fn verify_handshake( + &self, + operation_id: &str, + ) -> Result { + let (cancellation, _registration) = self.register_operation(operation_id)?; + self.ensure_verified( + operation_id, + cancellation, + self.config.startup_deadline, + self.observer.as_ref(), + ) + .await + } + + pub fn cancel_operation(&self, operation_id: &str) -> bool { + let running = self + .running + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if let Some(cancellation) = running.get(operation_id) { + cancellation.cancel(); + true + } else { + false + } + } + + async fn run_json_command( + &self, + operation_id: &str, + registry_path: &Path, + current_dir: Option<&Path>, + command_args: Vec, + deadline: Duration, + observer: &dyn LoopxProcessObserver, + ) -> Result { + let (cancellation, _registration) = self.register_operation(operation_id)?; + let verified = self + .ensure_verified( + operation_id, + cancellation.clone(), + deadline.min(self.config.startup_deadline), + observer, + ) + .await?; + let mut args = verified.prefix_args.clone(); + args.extend([ + OsString::from("--format"), + OsString::from("json"), + OsString::from("--registry"), + registry_path.as_os_str().to_owned(), + ]); + args.extend(command_args); + let output = self + .runner + .run( + LoopxCommandPlan { + operation_id: operation_id.to_string(), + executable: verified.executable, + args, + current_dir: current_dir.map(Path::to_path_buf), + environment: verified.environment, + deadline, + terminate_grace: self.config.terminate_grace, + }, + cancellation, + observer, + ) + .await?; + let payload = serde_json::from_str(&output.stdout).map_err(|error| { + LoopxCliAdapterError::InvalidJson { + message: error.to_string(), + } + })?; + Ok(LoopxJsonOutput { + payload, + stderr_tail: output.stderr_tail, + elapsed: output.elapsed, + }) + } + + async fn run_global_json_command( + &self, + operation_id: &str, + command_args: Vec, + deadline: Duration, + observer: &dyn LoopxProcessObserver, + ) -> Result { + let (cancellation, _registration) = self.register_operation(operation_id)?; + let verified = self + .ensure_verified( + operation_id, + cancellation.clone(), + deadline.min(self.config.startup_deadline), + observer, + ) + .await?; + let mut args = verified.prefix_args.clone(); + args.extend([OsString::from("--format"), OsString::from("json")]); + args.extend(command_args); + let output = self + .runner + .run( + LoopxCommandPlan { + operation_id: operation_id.to_string(), + executable: verified.executable, + args, + current_dir: None, + environment: verified.environment, + deadline, + terminate_grace: self.config.terminate_grace, + }, + cancellation, + observer, + ) + .await?; + let payload = serde_json::from_str(&output.stdout).map_err(|error| { + LoopxCliAdapterError::InvalidJson { + message: error.to_string(), + } + })?; + Ok(LoopxJsonOutput { + payload, + stderr_tail: output.stderr_tail, + elapsed: output.elapsed, + }) + } + + async fn ensure_verified( + &self, + operation_id: &str, + cancellation: CancellationToken, + startup_deadline: Duration, + observer: &dyn LoopxProcessObserver, + ) -> Result { + let mut verified_guard = self.verified.lock().await; + if let Some(verified) = verified_guard.as_ref() { + return Ok(verified.clone()); + } + + let candidate = self.select_candidate().await?; + if let Some(source_dir) = candidate.managed_source_dir.as_ref() { + let git = which::which("git").map_err(|error| LoopxCliAdapterError::Manifest { + message: format!("Git is required to verify managed LoopX source: {error}"), + })?; + let status = self + .runner + .run( + LoopxCommandPlan { + operation_id: operation_id.to_string(), + executable: git, + args: vec![ + OsString::from("-C"), + source_dir.as_os_str().to_owned(), + OsString::from("status"), + OsString::from("--porcelain"), + OsString::from("--untracked-files=all"), + ], + current_dir: None, + environment: BTreeMap::new(), + deadline: startup_deadline, + terminate_grace: self.config.terminate_grace, + }, + cancellation.clone(), + observer, + ) + .await?; + if !status.stdout.trim().is_empty() { + return Err(LoopxCliAdapterError::Manifest { + message: "managed LoopX source was modified; reinstall it from GitHub" + .to_string(), + }); + } + } + let version_output = self + .runner + .run( + LoopxCommandPlan::handshake( + operation_id, + candidate.executable.clone(), + &candidate.prefix_args, + &candidate.environment, + ["--version"], + startup_deadline, + self.config.terminate_grace, + ), + cancellation.clone(), + observer, + ) + .await?; + let actual_version = version_output.stdout.trim(); + if actual_version != LOOPX_PINNED_VERSION_OUTPUT { + return Err(LoopxCliAdapterError::VersionMismatch { + expected: LOOPX_PINNED_VERSION_OUTPUT.to_string(), + actual: actual_version.to_string(), + }); + } + + let schema_output = self + .runner + .run( + LoopxCommandPlan::handshake( + operation_id, + candidate.executable.clone(), + &candidate.prefix_args, + &candidate.environment, + ["--format", "json", "commands"], + startup_deadline, + self.config.terminate_grace, + ), + cancellation, + observer, + ) + .await?; + let schema_payload: Value = + serde_json::from_str(&schema_output.stdout).map_err(|error| { + LoopxCliAdapterError::InvalidJson { + message: error.to_string(), + } + })?; + let actual_schema = schema_payload + .get("schema_version") + .and_then(Value::as_str) + .unwrap_or_default(); + if schema_payload.get("ok").and_then(Value::as_bool) != Some(true) + || actual_schema != LOOPX_COMMAND_REFERENCE_SCHEMA + { + return Err(LoopxCliAdapterError::SchemaMismatch { + expected: LOOPX_COMMAND_REFERENCE_SCHEMA.to_string(), + actual: actual_schema.to_string(), + }); + } + + let verified = VerifiedLoopxCommand { + executable: candidate.executable, + prefix_args: candidate.prefix_args, + environment: with_utf8_stdio(candidate.environment), + source: candidate.source, + version: LOOPX_PINNED_VERSION.to_string(), + bundle_manifest_schema: candidate.bundle_manifest_schema, + command_reference_schema: LOOPX_COMMAND_REFERENCE_SCHEMA.to_string(), + sha256: candidate.sha256, + }; + *verified_guard = Some(verified.clone()); + Ok(verified) + } + + async fn select_candidate(&self) -> Result { + let bundle_dir = self.config.resource_dir.join("loopx"); + let executable = bundle_dir.join(if cfg!(windows) { "loopx.exe" } else { "loopx" }); + let manifest_path = bundle_dir.join("manifest.json"); + let executable_exists = tokio::fs::try_exists(&executable).await.map_err(|error| { + LoopxCliAdapterError::Manifest { + message: error.to_string(), + } + })?; + let manifest_exists = tokio::fs::try_exists(&manifest_path) + .await + .map_err(|error| LoopxCliAdapterError::Manifest { + message: error.to_string(), + })?; + + if executable_exists || manifest_exists { + if !executable_exists || !manifest_exists { + return Err(LoopxCliAdapterError::Manifest { + message: "bundle must contain both the executable and manifest.json" + .to_string(), + }); + } + let raw = tokio::fs::read(&manifest_path).await.map_err(|error| { + LoopxCliAdapterError::Manifest { + message: error.to_string(), + } + })?; + let manifest: BundledLoopxManifest = + serde_json::from_slice(&raw).map_err(|error| LoopxCliAdapterError::Manifest { + message: error.to_string(), + })?; + verify_manifest(&manifest)?; + let digest = sha256_file(&executable).await?; + let expected_digest = manifest + .sha256 + .strip_prefix("sha256:") + .unwrap_or(&manifest.sha256); + if digest != expected_digest { + return Err(LoopxCliAdapterError::Manifest { + message: "bundle executable checksum does not match manifest.json".to_string(), + }); + } + return Ok(LoopxCandidate { + executable, + prefix_args: Vec::new(), + environment: BTreeMap::new(), + managed_source_dir: None, + source: LoopxCommandSource::PackagedBundle, + bundle_manifest_schema: Some(manifest.schema_version), + sha256: Some(digest), + }); + } + + if let Some(candidate) = self.managed_source_candidate().await? { + return Ok(candidate); + } + + if self.config.system_fallback == LoopxSystemFallbackPolicy::ExactPinned { + let located = self + .locator + .locate() + .map_err(|message| LoopxCliAdapterError::Manifest { message })?; + if let Some(executable) = located { + return Ok(LoopxCandidate { + executable, + prefix_args: Vec::new(), + environment: BTreeMap::new(), + managed_source_dir: None, + source: LoopxCommandSource::FixedSystemCommand, + bundle_manifest_schema: None, + sha256: None, + }); + } + } + Err(LoopxCliAdapterError::Unavailable) + } + + async fn managed_source_candidate( + &self, + ) -> Result, LoopxCliAdapterError> { + let Some(source_dir) = self.config.managed_source_dir.as_ref() else { + return Ok(None); + }; + let manifest_path = source_dir.join(".git").join(MANAGED_SOURCE_MANIFEST); + if !tokio::fs::try_exists(&manifest_path) + .await + .map_err(|error| LoopxCliAdapterError::Manifest { + message: error.to_string(), + })? + { + return Ok(None); + } + let raw = tokio::fs::read(&manifest_path).await.map_err(|error| { + LoopxCliAdapterError::Manifest { + message: error.to_string(), + } + })?; + let manifest: ManagedLoopxSourceManifest = + serde_json::from_slice(&raw).map_err(|error| LoopxCliAdapterError::Manifest { + message: format!("invalid managed LoopX source manifest: {error}"), + })?; + verify_managed_source_manifest(&manifest)?; + let head = tokio::fs::read_to_string(source_dir.join(".git").join("HEAD")) + .await + .map_err(|error| LoopxCliAdapterError::Manifest { + message: format!("failed to read managed LoopX source revision: {error}"), + })?; + if head.trim() != LOOPX_PINNED_SOURCE_COMMIT { + return Err(LoopxCliAdapterError::Manifest { + message: format!( + "managed LoopX source revision mismatch: expected {LOOPX_PINNED_SOURCE_COMMIT}, got {}", + head.trim() + ), + }); + } + for required in ["pyproject.toml", "loopx/entrypoint.py"] { + if !tokio::fs::try_exists(source_dir.join(required)) + .await + .map_err(|error| LoopxCliAdapterError::Manifest { + message: error.to_string(), + })? + { + return Err(LoopxCliAdapterError::Manifest { + message: format!("managed LoopX source is missing {required}"), + }); + } + } + let python = self + .python_locator + .locate() + .map_err(|message| LoopxCliAdapterError::Manifest { message })? + .ok_or_else(|| LoopxCliAdapterError::Manifest { + message: "Python 3.11 or newer is required to run managed LoopX source".to_string(), + })?; + let mut environment = BTreeMap::new(); + environment.insert( + OsString::from("BITFUN_LOOPX_SOURCE"), + source_dir.as_os_str().to_owned(), + ); + Ok(Some(LoopxCandidate { + executable: python, + prefix_args: vec![ + OsString::from("-I"), + OsString::from("-c"), + OsString::from(PYTHON_LOOPX_ENTRYPOINT), + ], + environment, + managed_source_dir: Some(source_dir.clone()), + source: LoopxCommandSource::ManagedSource, + bundle_manifest_schema: None, + sha256: None, + })) + } + + async fn install_managed_source_checkout( + &self, + operation_id: &str, + staging_dir: &Path, + cancellation: CancellationToken, + progress: &dyn loopx_contract::LoopxCliProgressSink, + ) -> Result<(), LoopxCliAdapterError> { + let python = self + .python_locator + .locate() + .map_err(|message| LoopxCliAdapterError::Manifest { message })? + .ok_or_else(|| LoopxCliAdapterError::Manifest { + message: "Python 3.11 or newer is required to install LoopX from source" + .to_string(), + })?; + report_port_progress( + progress, + operation_id, + None, + loopx_contract::LoopxCliProgressStage::InstallingRuntime, + "Checking the Python runtime for managed LoopX source", + ); + let python_version = self + .runner + .run( + LoopxCommandPlan { + operation_id: operation_id.to_string(), + executable: python, + args: vec![OsString::from("--version")], + current_dir: None, + environment: BTreeMap::new(), + deadline: self.config.startup_deadline, + terminate_grace: self.config.terminate_grace, + }, + cancellation.clone(), + self.observer.as_ref(), + ) + .await?; + log::info!( + "LoopX install Python check completed: operation_id={operation_id}, duration_ms={}", + python_version.elapsed.as_millis() + ); + let version_text = if python_version.stdout.trim().is_empty() { + python_version.stderr_tail.join(" ") + } else { + python_version.stdout + }; + if !python_version_supported(&version_text) { + return Err(LoopxCliAdapterError::Manifest { + message: format!( + "Python 3.11 or newer is required to install LoopX from source; found {}", + version_text.trim() + ), + }); + } + let git = which::which("git").map_err(|error| LoopxCliAdapterError::Manifest { + message: format!("Git is required to download LoopX source: {error}"), + })?; + + report_port_progress( + progress, + operation_id, + None, + loopx_contract::LoopxCliProgressStage::InstallingRuntime, + "Downloading LoopX v0.5.1 source from GitHub", + ); + let clone_output = self + .runner + .run( + LoopxCommandPlan { + operation_id: operation_id.to_string(), + executable: git.clone(), + args: [ + "clone", + "--depth", + "1", + "--filter=blob:none", + "--sparse", + "--branch", + LOOPX_PINNED_VERSION_TAG, + "--single-branch", + LOOPX_SOURCE_REPOSITORY, + ] + .into_iter() + .map(OsString::from) + .chain(std::iter::once(staging_dir.as_os_str().to_owned())) + .collect(), + current_dir: None, + environment: BTreeMap::new(), + deadline: self.config.install_deadline, + terminate_grace: self.config.terminate_grace, + }, + cancellation.clone(), + self.observer.as_ref(), + ) + .await?; + log::info!( + "LoopX install GitHub clone completed: operation_id={operation_id}, duration_ms={}", + clone_output.elapsed.as_millis() + ); + + report_port_progress( + progress, + operation_id, + None, + loopx_contract::LoopxCliProgressStage::InstallingRuntime, + "Preparing only the LoopX runtime source files", + ); + let sparse_output = self + .runner + .run( + LoopxCommandPlan { + operation_id: operation_id.to_string(), + executable: git.clone(), + args: [ + OsString::from("-C"), + staging_dir.as_os_str().to_owned(), + OsString::from("sparse-checkout"), + OsString::from("set"), + OsString::from("--no-cone"), + OsString::from("/loopx/"), + OsString::from("/pyproject.toml"), + OsString::from("/LICENSE"), + OsString::from("/NOTICE"), + OsString::from("/LICENSE-MIT"), + OsString::from("/TRADEMARKS.md"), + ] + .into_iter() + .collect(), + current_dir: None, + environment: BTreeMap::new(), + deadline: self.config.install_deadline, + terminate_grace: self.config.terminate_grace, + }, + cancellation.clone(), + self.observer.as_ref(), + ) + .await?; + log::info!( + "LoopX install sparse checkout completed: operation_id={operation_id}, duration_ms={}", + sparse_output.elapsed.as_millis() + ); + + report_port_progress( + progress, + operation_id, + None, + loopx_contract::LoopxCliProgressStage::InstallingRuntime, + "Verifying the pinned LoopX source revision", + ); + let revision = self + .runner + .run( + LoopxCommandPlan { + operation_id: operation_id.to_string(), + executable: git, + args: vec![ + OsString::from("-C"), + staging_dir.as_os_str().to_owned(), + OsString::from("rev-parse"), + OsString::from("HEAD"), + ], + current_dir: None, + environment: BTreeMap::new(), + deadline: self.config.startup_deadline, + terminate_grace: self.config.terminate_grace, + }, + cancellation, + self.observer.as_ref(), + ) + .await?; + log::info!( + "LoopX install revision check completed: operation_id={operation_id}, duration_ms={}", + revision.elapsed.as_millis() + ); + if revision.stdout.trim() != LOOPX_PINNED_SOURCE_COMMIT { + return Err(LoopxCliAdapterError::Manifest { + message: format!( + "downloaded LoopX source revision mismatch: expected {LOOPX_PINNED_SOURCE_COMMIT}, got {}", + revision.stdout.trim() + ), + }); + } + for required in [ + "pyproject.toml", + "loopx/entrypoint.py", + "LICENSE", + "NOTICE", + "LICENSE-MIT", + "TRADEMARKS.md", + ] { + if !tokio::fs::try_exists(staging_dir.join(required)) + .await + .map_err(|error| LoopxCliAdapterError::Manifest { + message: error.to_string(), + })? + { + return Err(LoopxCliAdapterError::Manifest { + message: format!("downloaded LoopX source is missing {required}"), + }); + } + } + let manifest = ManagedLoopxSourceManifest { + schema_version: MANAGED_SOURCE_MANIFEST_SCHEMA, + source_repository: LOOPX_SOURCE_REPOSITORY.to_string(), + source_tag: LOOPX_PINNED_VERSION_TAG.to_string(), + source_commit: LOOPX_PINNED_SOURCE_COMMIT.to_string(), + loopx_version: LOOPX_PINNED_VERSION.to_string(), + }; + let raw = serde_json::to_vec_pretty(&manifest).map_err(|error| { + LoopxCliAdapterError::Manifest { + message: error.to_string(), + } + })?; + tokio::fs::write(staging_dir.join(".git").join(MANAGED_SOURCE_MANIFEST), raw) + .await + .map_err(|error| LoopxCliAdapterError::Manifest { + message: error.to_string(), + })?; + Ok(()) + } + + fn register_operation( + &self, + operation_id: &str, + ) -> Result<(CancellationToken, OperationRegistration), LoopxCliAdapterError> { + let mut running = self + .running + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if running.contains_key(operation_id) { + return Err(LoopxCliAdapterError::Conflict { + operation_id: operation_id.to_string(), + }); + } + let cancellation = CancellationToken::new(); + running.insert(operation_id.to_string(), cancellation.clone()); + Ok(( + cancellation, + OperationRegistration { + operation_id: operation_id.to_string(), + running: self.running.clone(), + }, + )) + } +} + +struct PortProcessObserver<'a> { + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + fallback: &'a dyn LoopxProcessObserver, + task_id: Option, + stage: loopx_contract::LoopxCliProgressStage, +} + +impl LoopxProcessObserver for PortProcessObserver<'_> { + fn on_progress(&self, progress: LoopxProcessProgress) { + self.fallback.on_progress(progress.clone()); + self.progress.report(loopx_contract::LoopxCliProgress { + operation_id: progress.operation_id, + task_id: self.task_id.clone(), + stage: self.stage, + message: progress.message, + occurred_at: progress.occurred_at_unix_ms.try_into().unwrap_or(i64::MAX), + }); + } +} + +impl loopx_contract::LoopxCliPort for LoopxCliProcessAdapter { + fn install_managed_source<'a>( + &'a self, + request: loopx_contract::LoopxCliInstallManagedSourceRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliInstallManagedSourceResult> + { + Box::pin(async move { + let operation_id = &request.call.operation_id; + let received_at = Instant::now(); + log::info!("LoopX managed source install received: operation_id={operation_id}"); + validate_operation_id(operation_id)?; + let lock_started_at = Instant::now(); + let _install = self.install_lock.lock().await; + log::info!( + "LoopX managed source install lock acquired: operation_id={operation_id}, wait_ms={}", + lock_started_at.elapsed().as_millis() + ); + let target_dir = self.config.managed_source_dir.clone().ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::Backend, + operation_id, + "managed LoopX source installation is not configured", + false, + ) + })?; + let parent = target_dir.parent().ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "managed LoopX source path has no parent directory", + false, + ) + })?; + tokio::fs::create_dir_all(parent).await.map_err(|error| { + port_error( + loopx_contract::LoopxCliErrorKind::Io, + operation_id, + format!("failed to create managed LoopX directory: {error}"), + true, + ) + })?; + let suffix = format!("{}-{}", std::process::id(), now_unix_ms()); + let staging_dir = parent.join(format!(".loopx-source-install-{suffix}")); + let backup_dir = parent.join(format!(".loopx-source-backup-{suffix}")); + let (cancellation, _registration) = self + .register_operation(operation_id) + .map_err(|error| map_port_error(error, operation_id))?; + + if let Err(error) = self + .install_managed_source_checkout( + operation_id, + &staging_dir, + cancellation.clone(), + progress, + ) + .await + { + let _ = tokio::fs::remove_dir_all(&staging_dir).await; + return Err(map_port_error(error, operation_id)); + } + + report_port_progress( + progress, + operation_id, + None, + loopx_contract::LoopxCliProgressStage::InstallingRuntime, + "Activating the verified LoopX source", + ); + let had_previous = tokio::fs::try_exists(&target_dir).await.map_err(|error| { + port_error( + loopx_contract::LoopxCliErrorKind::Io, + operation_id, + error.to_string(), + true, + ) + })?; + if had_previous { + tokio::fs::rename(&target_dir, &backup_dir) + .await + .map_err(|error| { + port_error( + loopx_contract::LoopxCliErrorKind::Io, + operation_id, + format!("failed to stage the previous LoopX source: {error}"), + true, + ) + })?; + } + if let Err(error) = tokio::fs::rename(&staging_dir, &target_dir).await { + if had_previous { + let _ = tokio::fs::rename(&backup_dir, &target_dir).await; + } + let _ = tokio::fs::remove_dir_all(&staging_dir).await; + return Err(port_error( + loopx_contract::LoopxCliErrorKind::Io, + operation_id, + format!("failed to activate managed LoopX source: {error}"), + true, + )); + } + + self.verified.lock().await.take(); + if let Err(error) = self + .ensure_verified( + operation_id, + cancellation, + self.config.startup_deadline, + self.observer.as_ref(), + ) + .await + { + self.verified.lock().await.take(); + let _ = tokio::fs::remove_dir_all(&target_dir).await; + if had_previous { + let _ = tokio::fs::rename(&backup_dir, &target_dir).await; + } + return Err(map_port_error(error, operation_id)); + } + if had_previous { + let _ = tokio::fs::remove_dir_all(&backup_dir).await; + } + log::info!( + "LoopX managed source install activated: operation_id={operation_id}, duration_ms={}", + received_at.elapsed().as_millis() + ); + Ok(loopx_contract::LoopxCliInstallManagedSourceResult { + source_repository: LOOPX_SOURCE_REPOSITORY.to_string(), + source_tag: LOOPX_PINNED_VERSION_TAG.to_string(), + source_commit: LOOPX_PINNED_SOURCE_COMMIT.to_string(), + install_path: target_dir.to_string_lossy().into_owned(), + loopx_version: LOOPX_PINNED_VERSION.to_string(), + }) + }) + } + + fn handshake<'a>( + &'a self, + request: loopx_contract::LoopxCliHandshakeRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliManifest> { + Box::pin(async move { + validate_operation_id(&request.call.operation_id)?; + report_port_progress( + progress, + &request.call.operation_id, + None, + loopx_contract::LoopxCliProgressStage::StartingSidecar, + "Selecting the managed LoopX executable", + ); + if request.required_loopx_version != LOOPX_PINNED_VERSION { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::VersionMismatch, + &request.call.operation_id, + format!( + "adapter is pinned to LoopX {LOOPX_PINNED_VERSION}; requested {}", + request.required_loopx_version + ), + false, + )); + } + if request.required_schema_version != loopx_contract::LOOPX_CLI_SCHEMA_VERSION { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + &request.call.operation_id, + format!( + "adapter schema is {}; requested {}", + loopx_contract::LOOPX_CLI_SCHEMA_VERSION, + request.required_schema_version + ), + false, + )); + } + let deadline = effective_deadline( + request.call.deadline_at, + self.config.startup_deadline, + &request.call.operation_id, + )?; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: None, + stage: loopx_contract::LoopxCliProgressStage::Handshake, + }; + let (cancellation, _registration) = self + .register_operation(&request.call.operation_id) + .map_err(|error| map_port_error(error, &request.call.operation_id))?; + let verified = self + .ensure_verified( + &request.call.operation_id, + cancellation, + deadline, + &observer, + ) + .await + .map_err(|error| map_port_error(error, &request.call.operation_id))?; + let mut capabilities = vec![ + "issue_fix_workflow_plan_v0".to_string(), + "goal_bootstrap_v0".to_string(), + "loopx_turn_plan_v0".to_string(), + "custom_agent_runner_v0".to_string(), + "typed_gate_decision_v0".to_string(), + "managed_process_tree_v1".to_string(), + ]; + if self.intake_metadata_configured { + capabilities.push("intake_metadata_provider_v1".to_string()); + } + Ok(loopx_contract::LoopxCliManifest { + adapter_version: env!("CARGO_PKG_VERSION").to_string(), + loopx_version: verified.version, + schema_version: loopx_contract::LOOPX_CLI_SCHEMA_VERSION, + executable: loopx_contract::LoopxCliExecutableIdentity { + source: match verified.source { + LoopxCommandSource::PackagedBundle => { + loopx_contract::LoopxCliSource::Bundled + } + LoopxCommandSource::ManagedSource => { + loopx_contract::LoopxCliSource::PythonFallback + } + LoopxCommandSource::FixedSystemCommand => { + loopx_contract::LoopxCliSource::System + } + }, + identity: match verified.source { + LoopxCommandSource::PackagedBundle => { + "bitfun-bundled-loopx-v0.5.1".to_string() + } + LoopxCommandSource::ManagedSource => { + "bitfun-managed-github-source-loopx-v0.5.1".to_string() + } + LoopxCommandSource::FixedSystemCommand => { + "fixed-system-loopx-v0.5.1".to_string() + } + }, + path: Some(verified.executable.to_string_lossy().into_owned()), + sha256: verified.sha256.map(|digest| format!("sha256:{digest}")), + }, + capabilities, + }) + }) + } + + fn resolve_intake<'a>( + &'a self, + request: loopx_contract::LoopxCliResolveIntakeRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliResolveIntakeResult> { + Box::pin(async move { + validate_operation_id(&request.call.operation_id)?; + let deadline = effective_deadline( + request.call.deadline_at, + self.config.command_deadline, + &request.call.operation_id, + )?; + report_port_progress( + progress, + &request.call.operation_id, + None, + loopx_contract::LoopxCliProgressStage::ResolvingIntake, + "Resolving live repository metadata", + ); + self.intake_metadata.resolve(&request, deadline).await + }) + } + + fn probe_github_auth<'a>( + &'a self, + request: loopx_contract::LoopxGithubAuthProbeRequest, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxGithubAuthProbe> { + Box::pin(async move { + validate_operation_id(&request.call.operation_id)?; + let deadline = effective_deadline( + request.call.deadline_at, + self.config.command_deadline, + &request.call.operation_id, + )?; + self.intake_metadata.probe_auth(deadline).await + }) + } + + fn plan_item<'a>( + &'a self, + request: loopx_contract::LoopxCliPlanItemRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliIntakePlan> { + Box::pin(async move { + validate_goal_context(&request.context)?; + validate_github_item(&request.item, &request.context.call.operation_id)?; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: Some(request.context.task_id.clone()), + stage: loopx_contract::LoopxCliProgressStage::PlanningItem, + }; + let operation_id = &request.context.call.operation_id; + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::PlanningItem, + "Building the pinned LoopX issue-fix plan", + ); + let deadline = effective_deadline( + request.context.call.deadline_at, + self.config.command_deadline, + operation_id, + )?; + let output = self + .run_json_command( + operation_id, + Path::new(&request.context.registry_path), + Some(Path::new(&request.context.worktree_path)), + plan_item_args(&request), + deadline, + &observer, + ) + .await + .map_err(|error| map_port_error(error, operation_id))?; + require_payload_ok(&output.payload, operation_id)?; + require_schema( + &output.payload, + "issue_fix_workflow_plan_packet_v0", + operation_id, + )?; + let previews = output + .payload + .get("ordered_loopx_todo_writeback_preview") + .and_then(Value::as_array) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "workflow plan did not contain ordered_loopx_todo_writeback_preview", + false, + ) + })?; + let mut todos = Vec::with_capacity(previews.len()); + for preview in previews { + let role = required_json_string(preview, "role", operation_id)?; + let task_class = required_json_string(preview, "task_class", operation_id)?; + let text = required_json_string(preview, "text", operation_id)?; + let action_kind = preview + .get("action_kind") + .and_then(Value::as_str) + .map(str::to_string); + let target_key = preview + .get("target_key") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + todos.push(loopx_contract::LoopxCliTodoPlan { + role, + task_class, + action_kind, + text, + target_key, + }); + } + if todos.is_empty() { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::Backend, + operation_id, + "workflow plan produced no writable todos", + true, + )); + } + // LoopX's issue-fix workflow contract pins the goal objective to + // the host-resolved issue title; the packet itself carries no + // objective field. + let objective = compact_objective(&request.item, &request.title); + Ok(loopx_contract::LoopxCliIntakePlan { + item: request.item, + objective, + todos, + }) + }) + } + + fn create_goal<'a>( + &'a self, + request: loopx_contract::LoopxCliCreateGoalRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliCreateGoalResult> { + Box::pin(async move { + validate_goal_context(&request.context)?; + validate_nonempty( + "goal_id", + &request.goal_id, + &request.context.call.operation_id, + )?; + validate_nonempty( + "agent_id", + &request.agent_id, + &request.context.call.operation_id, + )?; + if request.intake.todos.is_empty() { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + &request.context.call.operation_id, + "create_goal requires at least one planned todo", + false, + )); + } + let operation_id = &request.context.call.operation_id; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: Some(request.context.task_id.clone()), + stage: loopx_contract::LoopxCliProgressStage::CreatingGoal, + }; + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::CreatingGoal, + "Creating one LoopX goal for the selected item", + ); + + let bootstrap = + run_port_command(self, &request.context, bootstrap_args(&request), &observer) + .await?; + require_payload_ok(&bootstrap.payload, operation_id)?; + let state_action = bootstrap + .payload + .get("state_action") + .and_then(Value::as_str) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "bootstrap response did not contain state_action", + false, + ) + })?; + let created = matches!(state_action, "created" | "replaced"); + + let registration = run_port_command( + self, + &request.context, + register_agent_args(&request), + &observer, + ) + .await?; + require_payload_ok(®istration.payload, operation_id)?; + + let existing_todos = run_port_command( + self, + &request.context, + list_todos_args(&request.goal_id), + &observer, + ) + .await?; + require_payload_ok(&existing_todos.payload, operation_id)?; + let existing_todos = existing_todos + .payload + .get("todos") + .and_then(Value::as_array) + .cloned() + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "todo list response did not contain todos", + false, + ) + })?; + for todo in &request.intake.todos { + if existing_todos + .iter() + .any(|existing| todo_matches(existing, todo)) + { + continue; + } + let result = run_port_command( + self, + &request.context, + add_todo_args(&request, todo)?, + &observer, + ) + .await?; + require_payload_ok(&result.payload, operation_id)?; + } + + let mut inspection_args = turn_plan_args(&request.goal_id, &request.agent_id, None); + extend_available_capability_args( + &mut inspection_args, + &request.context.available_capabilities, + operation_id, + )?; + let inspection = + run_port_command(self, &request.context, inspection_args, &observer).await?; + require_payload_ok(&inspection.payload, operation_id)?; + let durable_revision = extract_durable_revision(&inspection.payload, operation_id)?; + Ok(loopx_contract::LoopxCliCreateGoalResult { + goal_id: request.goal_id, + created, + durable_revision, + }) + }) + } + + fn inspect_goal<'a>( + &'a self, + request: loopx_contract::LoopxCliInspectGoalRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliGoalSnapshot> { + Box::pin(async move { + validate_goal_context(&request.context)?; + let operation_id = &request.context.call.operation_id; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: Some(request.context.task_id.clone()), + stage: loopx_contract::LoopxCliProgressStage::InspectingGoal, + }; + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::InspectingGoal, + "Inspecting durable LoopX goal state", + ); + let mut args = turn_plan_args(&request.goal_id, &request.agent_id, None); + extend_available_capability_args( + &mut args, + &request.context.available_capabilities, + operation_id, + )?; + let mut snapshot = + inspect_goal_snapshot(self, &request.goal_id, &request.context, args, &observer) + .await?; + if snapshot.waiting_user_todo_count > 0 + || snapshot.run_decision == loopx_contract::LoopxCliRunDecision::WaitingForUser + { + let todos = run_port_command( + self, + &request.context, + list_todos_args(&request.goal_id), + &observer, + ) + .await?; + snapshot.pending_user_gate = + Some(project_pending_user_gate(&todos.payload, operation_id)?); + } + Ok(snapshot) + }) + } + + fn build_turn<'a>( + &'a self, + request: loopx_contract::LoopxCliBuildTurnRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliBuildTurnResult> { + Box::pin(async move { + validate_goal_context(&request.context)?; + let operation_id = &request.context.call.operation_id; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: Some(request.context.task_id.clone()), + stage: loopx_contract::LoopxCliProgressStage::BuildingTurn, + }; + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::BuildingTurn, + "Building a fresh LoopX custom-runner turn contract", + ); + let turn_id = stable_turn_id(&request); + let mut guard_args = + quota_guard_args(&request.goal_id, &request.agent_id, None, &turn_id); + guard_args.push(OsString::from("--turn-envelope")); + extend_available_capability_args( + &mut guard_args, + &request.context.available_capabilities, + operation_id, + )?; + let guard = run_port_command(self, &request.context, guard_args, &observer).await?; + require_payload_ok(&guard.payload, operation_id)?; + require_schema(&guard.payload, "loopx_turn_envelope_v0", operation_id)?; + let durable_revision = extract_durable_revision(&guard.payload, operation_id)?; + if durable_revision != request.expected_durable_revision { + // `turn plan` and `quota should-run` are two different LoopX + // envelope builders; loopx does not promise their action + // signatures agree for identical state. The fresh guard packet + // below is the authoritative execution contract, and durable + // settlement evidence is verified against the same turn + // identity, so a mismatch here is informational only. + log::info!( + "LoopX guard revision differs from inspect projection: task_id={} goal={} turn_instance={} inspect_revision={} guard_revision={}", + request.context.task_id, + request.goal_id, + turn_id, + request.expected_durable_revision, + durable_revision + ); + } + require_turn_owner( + &guard.payload, + &request.goal_id, + &request.agent_id, + operation_id, + )?; + if guard.payload.get("should_run").and_then(Value::as_bool) != Some(true) { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::Conflict, + operation_id, + "LoopX quota guard no longer permits host execution", + true, + )); + } + let settlement_binding = planned_settlement_binding(&guard.payload); + log::info!( + "LoopX turn guard accepted: task_id={} goal={} turn_instance={} should_run=true revision={} binding={:?}", + request.context.task_id, + request.goal_id, + turn_id, + durable_revision, + settlement_binding.as_ref().map(|binding| match binding { + SettlementBinding::Todo { todo_id } => format!("todo:{todo_id}"), + SettlementBinding::AutonomousReplan { obligation_id } => { + format!("replan:{obligation_id}") + } + }), + ); + let settlement_token = planned_settlement_token( + &guard.payload, + &request.goal_id, + &request.agent_id, + &turn_id, + operation_id, + )?; + let verified = self.verified.lock().await.clone().ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::Backend, + operation_id, + "LoopX executable identity disappeared after handshake", + true, + ) + })?; + let agent_instruction = render_agent_reentry_instruction( + &guard.payload, + &verified, + &request.context.registry_path, + &turn_id, + settlement_binding.as_ref(), + operation_id, + )?; + Ok(loopx_contract::LoopxCliBuildTurnResult { + goal_id: request.goal_id, + turn_id, + agent_instruction, + settlement_token, + durable_revision, + deadline_at: request.context.call.deadline_at, + }) + }) + } + + fn viewer_merge_authority<'a>( + &'a self, + _context: &'a loopx_contract::LoopxCliGoalContext, + repository: &'a loopx_contract::LoopxRepositoryKey, + ) -> loopx_contract::LoopxCliFuture<'a, Option> { + Box::pin(async move { + self.intake_metadata + .viewer_merge_authority(repository, Duration::from_secs(15)) + .await + }) + } + + fn answer_gate<'a>( + &'a self, + request: loopx_contract::LoopxCliAnswerGateRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliAnswerGateResult> { + Box::pin(async move { + validate_goal_context(&request.context)?; + let operation_id = &request.context.call.operation_id; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: Some(request.context.task_id.clone()), + stage: loopx_contract::LoopxCliProgressStage::AnsweringGate, + }; + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::AnsweringGate, + "Applying the typed LoopX gate decision", + ); + let decision = run_port_command( + self, + &request.context, + answer_gate_args(&request)?, + &observer, + ) + .await?; + require_payload_ok(&decision.payload, operation_id)?; + let mut inspection_args = turn_plan_args(&request.goal_id, &request.agent_id, None); + extend_available_capability_args( + &mut inspection_args, + &request.context.available_capabilities, + operation_id, + )?; + let snapshot = inspect_goal_snapshot( + self, + &request.goal_id, + &request.context, + inspection_args, + &observer, + ) + .await?; + Ok(loopx_contract::LoopxCliAnswerGateResult { + goal_id: request.goal_id, + gate_id: request.gate_id, + applied: true, + durable_revision: snapshot.durable_revision, + goal_state: snapshot.state, + }) + }) + } + + fn verify_turn_settlement<'a>( + &'a self, + request: loopx_contract::LoopxCliSettleTurnRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliSettleTurnResult> { + Box::pin(async move { + validate_goal_context(&request.context)?; + validate_nonempty( + "agent_id", + &request.agent_id, + &request.context.call.operation_id, + )?; + report_port_progress( + progress, + &request.context.call.operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::SettlingTurn, + "Verifying durable LoopX progress and quota settlement evidence", + ); + let operation_id = &request.context.call.operation_id; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: Some(request.context.task_id.clone()), + stage: loopx_contract::LoopxCliProgressStage::SettlingTurn, + }; + let mut inspection_args = + turn_plan_args(&request.goal_id, &request.agent_id, Some(&request.turn_id)); + extend_available_capability_args( + &mut inspection_args, + &request.context.available_capabilities, + operation_id, + )?; + // The settlement inspection is read-only bookkeeping: its snapshot + // feeds `after_revision` and the terminal-state check, while the + // durable evidence below comes from `history`. When the pinned CLI + // answers this inspection with the plan-exhausted replan-lineage + // contract error (all todos done or blocked, open replan + // obligation, no selected todo), salvage the typed payload instead + // of failing the settlement of a turn that did complete its + // writeback and quota spend: the post-settlement goal projection + // then drives one autonomous replan turn bound to the obligation + // (or parks the task if no obligation remains) through the normal + // frontier handling. + let (snapshot, inspection_payload) = match run_port_command_raw( + self, + &request.context, + inspection_args, + &observer, + ) + .await + { + Ok(inspection) => { + require_payload_ok(&inspection.payload, operation_id)?; + require_schema(&inspection.payload, "loopx_turn_plan_v0", operation_id)?; + ( + project_goal_snapshot(&request.goal_id, &inspection.payload, operation_id)?, + Some(inspection.payload), + ) + } + Err(RawPortError::Port(error)) => return Err(error), + Err(RawPortError::Adapter(error)) => { + let Some(payload) = salvage_replan_lineage_payload(&error) else { + return Err(map_port_error(error, operation_id)); + }; + log::warn!( + "LoopX settlement inspection hit the plan-exhausted replan lineage contract error; continuing settlement from durable history evidence: task_id={} goal={} turn={} error=\"{}\"", + request.context.task_id, + request.goal_id, + request.turn_id, + LOOPX_REPLAN_LINEAGE_ERROR + ); + ( + salvaged_replan_lineage_snapshot(&request.goal_id, &payload, operation_id) + .map(|(snapshot, _)| snapshot) + .ok_or_else(|| map_port_error(error, operation_id))?, + None, + ) + } + }; + if is_legacy_turn_key(&request.settlement_token) { + if let Some(receipt) = inspection_payload.as_ref().and_then(|payload| { + matching_settlement_receipt(payload, &request.settlement_token) + }) { + return project_legacy_settlement(&request, &snapshot, receipt, operation_id); + } + } + let history = run_port_command( + self, + &request.context, + settlement_history_args(&request.goal_id), + &observer, + ) + .await?; + require_payload_ok(&history.payload, operation_id)?; + let evidence = matching_durable_progress( + &history.payload, + &request.goal_id, + &request.agent_id, + &request.turn_id, + &request.settlement_token, + operation_id, + )?; + let Some(evidence) = evidence else { + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::SettlingTurn, + "No matching durable LoopX writeback was found for this turn", + ); + let status = if matches!( + request.agent_status, + loopx_contract::LoopxAgentTurnStatus::Failed + | loopx_contract::LoopxAgentTurnStatus::Cancelled + | loopx_contract::LoopxAgentTurnStatus::Interrupted + ) { + loopx_contract::LoopxCliSettlementStatus::RetryRequired + } else { + loopx_contract::LoopxCliSettlementStatus::NoDurableProgress + }; + return Ok(loopx_contract::LoopxCliSettleTurnResult { + goal_id: request.goal_id, + turn_id: request.turn_id, + status, + before_revision: request.expected_durable_revision, + after_revision: snapshot.durable_revision, + scheduler_hint_ms: snapshot.scheduler_hint_ms, + ..loopx_contract::LoopxCliSettleTurnResult::default() + }); + }; + if !evidence.quota_spent { + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::SettlingTurn, + "Matching durable LoopX writeback exists, but its quota settlement is missing", + ); + return Ok(loopx_contract::LoopxCliSettleTurnResult { + goal_id: request.goal_id, + turn_id: request.turn_id, + status: loopx_contract::LoopxCliSettlementStatus::RetryRequired, + before_revision: request.expected_durable_revision, + after_revision: snapshot.durable_revision, + scheduler_hint_ms: snapshot.scheduler_hint_ms, + ..loopx_contract::LoopxCliSettleTurnResult::default() + }); + } + report_port_progress( + progress, + operation_id, + Some(request.context.task_id.clone()), + loopx_contract::LoopxCliProgressStage::SettlingTurn, + "Matched validated LoopX progress and quota settlement evidence", + ); + Ok(loopx_contract::LoopxCliSettleTurnResult { + goal_id: request.goal_id, + turn_id: request.turn_id, + receipt_id: evidence.effect_id, + status: if snapshot.state == loopx_contract::LoopxCliGoalState::Completed { + loopx_contract::LoopxCliSettlementStatus::GoalCompleted + } else { + loopx_contract::LoopxCliSettlementStatus::Settled + }, + before_revision: request.expected_durable_revision, + after_revision: snapshot.durable_revision, + validation_succeeded: true, + scheduler_hint_ms: snapshot.scheduler_hint_ms, + }) + }) + } + + fn cancel<'a>( + &'a self, + request: loopx_contract::LoopxCliCancelRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliCancelResult> { + Box::pin(async move { + validate_operation_id(&request.call.operation_id)?; + validate_nonempty( + "target_operation_id", + &request.target_operation_id, + &request.call.operation_id, + )?; + report_port_progress( + progress, + &request.call.operation_id, + None, + loopx_contract::LoopxCliProgressStage::Cancelling, + "Cancelling the managed LoopX process tree", + ); + let cancelled = self.cancel_operation(&request.target_operation_id); + Ok(loopx_contract::LoopxCliCancelResult { + operation_id: request.call.operation_id, + target_operation_id: request.target_operation_id, + cancelled, + }) + }) + } + + fn reset_goals<'a>( + &'a self, + request: loopx_contract::LoopxCliResetGoalsRequest, + progress: &'a dyn loopx_contract::LoopxCliProgressSink, + ) -> loopx_contract::LoopxCliFuture<'a, loopx_contract::LoopxCliResetGoalsResult> { + Box::pin(async move { + validate_operation_id(&request.call.operation_id)?; + let goal_ids = request + .goal_ids + .into_iter() + .map(|goal_id| goal_id.trim().to_string()) + .filter(|goal_id| !goal_id.is_empty()) + .collect::>() + .into_iter() + .collect::>(); + if goal_ids.is_empty() { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + &request.call.operation_id, + "reset_goals requires at least one explicit goal id", + false, + )); + } + let operation_id = &request.call.operation_id; + let observer = PortProcessObserver { + progress, + fallback: self.observer.as_ref(), + task_id: None, + stage: loopx_contract::LoopxCliProgressStage::Cancelling, + }; + report_port_progress( + progress, + operation_id, + None, + loopx_contract::LoopxCliProgressStage::Cancelling, + "Retiring global LoopX goal routes and archiving runtime state", + ); + let deadline = effective_deadline( + request.call.deadline_at, + self.config.command_deadline, + operation_id, + )?; + let mut result = loopx_contract::LoopxCliResetGoalsResult { + requested_goal_ids: goal_ids.clone(), + ..loopx_contract::LoopxCliResetGoalsResult::default() + }; + + for goal_id in goal_ids { + let retired = run_idempotent_global_command( + self, + operation_id, + retire_global_goal_args(&goal_id), + "goal_id not found in global registry:", + deadline, + &observer, + ) + .await?; + if let Some(output) = retired { + require_payload_ok(&output.payload, operation_id)?; + require_schema( + &output.payload, + "loopx_global_goal_retirement_v0", + operation_id, + )?; + let retired_ids = output + .payload + .get("retired_goal_ids") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if !retired_ids + .iter() + .any(|value| value.as_str() == Some(&goal_id)) + { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + format!("retire-global-goal did not confirm goal {goal_id}"), + false, + )); + } + result.retired_goal_ids.push(goal_id.clone()); + if let Some(path) = output.payload.get("backup_path").and_then(Value::as_str) { + result.backup_paths.push(path.to_string()); + } + } else { + result.already_absent_goal_ids.push(goal_id.clone()); + } + + let archived = run_idempotent_global_command( + self, + operation_id, + archive_runtime_args(&goal_id), + "runtime goal directory does not exist:", + deadline, + &observer, + ) + .await?; + if let Some(output) = archived { + require_payload_ok(&output.payload, operation_id)?; + if output.payload.get("archived").and_then(Value::as_bool) != Some(true) { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + format!("archive-runtime did not confirm goal {goal_id}"), + false, + )); + } + result.archived_goal_ids.push(goal_id.clone()); + if let Some(path) = output.payload.get("archive_path").and_then(Value::as_str) { + result.archive_paths.push(path.to_string()); + } + } else { + result.missing_runtime_goal_ids.push(goal_id); + } + } + Ok(result) + }) + } +} + +fn matching_settlement_receipt<'a>(payload: &'a Value, turn_key: &str) -> Option<&'a Value> { + payload + .pointer("/transaction/receipts") + .and_then(Value::as_array)? + .iter() + .find(|receipt| { + receipt.get("turn_key").and_then(Value::as_str) == Some(turn_key) + || receipt.get("settlement_token").and_then(Value::as_str) == Some(turn_key) + }) +} + +fn turn_envelope<'a>( + payload: &'a Value, + operation_id: &str, +) -> loopx_contract::LoopxCliResult<&'a Value> { + if payload.get("schema_version").and_then(Value::as_str) == Some("loopx_turn_envelope_v0") { + return Ok(payload); + } + payload.get("turn_envelope").ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "LoopX response omitted the TurnEnvelope", + false, + ) + }) +} + +fn require_turn_owner( + packet: &Value, + goal_id: &str, + agent_id: &str, + operation_id: &str, +) -> loopx_contract::LoopxCliResult<()> { + let envelope = turn_envelope(packet, operation_id)?; + if envelope.get("goal_id").and_then(Value::as_str) != Some(goal_id) + || envelope.get("agent_id").and_then(Value::as_str) != Some(agent_id) + { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "LoopX TurnEnvelope owner did not match the requested goal and agent", + false, + )); + } + Ok(()) +} + +fn planned_settlement_token( + payload: &Value, + goal_id: &str, + agent_id: &str, + turn_id: &str, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + if let Some(binding) = planned_settlement_binding(payload) { + return Ok(binding.effect_id(goal_id, agent_id, turn_id)); + } + + payload + .pointer("/transaction/turn_key") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "LoopX turn packet did not contain a selected Todo or transaction.turn_key", + false, + ) + }) +} + +fn planned_settlement_binding(payload: &Value) -> Option { + let route_kind = payload.pointer("/route/kind").and_then(Value::as_str); + if route_kind == Some("replan_required") { + let obligation_id = payload + .pointer("/turn_envelope/replan_action_packet/obligation_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty())?; + return Some(SettlementBinding::AutonomousReplan { + obligation_id: obligation_id.to_string(), + }); + } + + if let Some(obligation_id) = [ + "/replan_action_packet/obligation_id", + "/turn_envelope/replan_action_packet/obligation_id", + ] + .into_iter() + .find_map(|pointer| { + payload + .pointer(pointer) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + }) { + return Some(SettlementBinding::AutonomousReplan { + obligation_id: obligation_id.to_string(), + }); + } + + planned_todo_id(payload).map(|todo_id| SettlementBinding::Todo { + todo_id: todo_id.to_string(), + }) +} + +fn planned_todo_id(payload: &Value) -> Option<&str> { + [ + "/route/selected_todo/todo_id", + "/turn_envelope/action/selected_todo/todo_id", + "/action/selected_todo/todo_id", + ] + .into_iter() + .find_map(|pointer| { + payload + .pointer(pointer) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + }) +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum SettlementBinding { + Todo { todo_id: String }, + AutonomousReplan { obligation_id: String }, +} + +impl SettlementBinding { + fn effect_id(&self, goal_id: &str, agent_id: &str, turn_id: &str) -> String { + match self { + Self::Todo { todo_id } => settlement_effect_id(goal_id, agent_id, todo_id, turn_id), + Self::AutonomousReplan { obligation_id } => { + replan_settlement_effect_id(goal_id, agent_id, obligation_id, turn_id) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DurableSettlementEvidence { + effect_id: String, + binding: SettlementBinding, + quota_spent: bool, +} + +fn matching_durable_progress( + payload: &Value, + goal_id: &str, + agent_id: &str, + turn_id: &str, + settlement_token: &str, + operation_id: &str, +) -> loopx_contract::LoopxCliResult> { + let goals = payload + .get("goals") + .and_then(Value::as_array) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "LoopX history response did not contain goals", + false, + ) + })?; + let Some(goal) = goals + .iter() + .find(|goal| goal.get("id").and_then(Value::as_str) == Some(goal_id)) + else { + return Ok(None); + }; + let runs = goal + .get("latest_runs") + .and_then(Value::as_array) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "LoopX history goal did not contain latest_runs", + false, + ) + })?; + + let mut accountable_effects = BTreeMap::::new(); + let mut quota_effects = BTreeSet::::new(); + for run in runs { + let Some((effect_id, binding)) = matching_run_identity(run, goal_id, agent_id, turn_id) + else { + continue; + }; + match run.get("classification").and_then(Value::as_str) { + Some("quota_slot_spent") => { + quota_effects.insert(effect_id); + } + _ if run_has_accountable_progress(run) => { + accountable_effects.insert(effect_id, binding); + } + _ => {} + } + } + + // New turns are fenced to the exact selected todo or replan obligation. + // Legacy turn keys predate that binding, so persisted in-flight tasks may + // still settle by their exact goal, agent, and host-issued turn identity. + let candidates = if is_legacy_turn_key(settlement_token) { + accountable_effects + } else { + accountable_effects + .into_iter() + .filter(|(effect_id, _)| settlement_token == effect_id) + .collect::>() + }; + // Prefer a candidate whose quota spend already settled; otherwise take the + // first deterministic entry. + let chosen = candidates + .iter() + .find(|(effect_id, _)| quota_effects.contains(*effect_id)) + .or_else(|| candidates.iter().next()); + let Some((effect_id, binding)) = chosen else { + return Ok(None); + }; + Ok(Some(DurableSettlementEvidence { + effect_id: effect_id.clone(), + binding: binding.clone(), + quota_spent: quota_effects.contains(effect_id), + })) +} + +fn run_has_accountable_progress(run: &Value) -> bool { + if let Some(observation) = run + .get("progress_observation") + .filter(|observation| !observation.is_null()) + { + let typed_progress = observation.get("schema_version").and_then(Value::as_str) + == Some("typed_progress_observation_v0") + && matches!( + observation.get("result_class").and_then(Value::as_str), + Some("advanced" | "no_followup") + ); + if typed_progress { + return true; + } + } + + matches!( + run.get("delivery_outcome").and_then(Value::as_str), + Some("outcome_progress" | "primary_goal_outcome") + ) +} + +fn matching_run_identity( + run: &Value, + goal_id: &str, + agent_id: &str, + turn_id: &str, +) -> Option<(String, SettlementBinding)> { + let identity = run.get("settlement_identity")?; + let effect_id = identity + .get("effect_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty())? + .to_string(); + let exact_owner = identity.get("goal_id").and_then(Value::as_str) == Some(goal_id) + && identity.get("agent_id").and_then(Value::as_str) == Some(agent_id) + && identity.get("turn_instance_id").and_then(Value::as_str) == Some(turn_id) + && run.get("goal_id").and_then(Value::as_str) == Some(goal_id) + && run.get("agent_id").and_then(Value::as_str) == Some(agent_id) + && run.get("turn_instance_id").and_then(Value::as_str) == Some(turn_id); + if !exact_owner { + return None; + } + + let binding = match identity.get("schema_version").and_then(Value::as_str) { + Some("quota_settlement_identity_v0") => { + let todo_id = identity + .get("todo_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty())?; + if run.get("todo_id").and_then(Value::as_str) != Some(todo_id) { + return None; + } + SettlementBinding::Todo { + todo_id: todo_id.to_string(), + } + } + Some("quota_settlement_identity_v1") + if identity.get("binding_kind").and_then(Value::as_str) + == Some("autonomous_replan") => + { + let obligation_id = identity + .get("replan_obligation_id") + .or_else(|| identity.get("binding_id")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty())?; + if run.get("replan_obligation_id").and_then(Value::as_str) != Some(obligation_id) { + return None; + } + SettlementBinding::AutonomousReplan { + obligation_id: obligation_id.to_string(), + } + } + _ => return None, + }; + (effect_id == binding.effect_id(goal_id, agent_id, turn_id)).then_some((effect_id, binding)) +} + +fn settlement_effect_id(goal_id: &str, agent_id: &str, todo_id: &str, turn_id: &str) -> String { + format!("{goal_id}:{agent_id}:{todo_id}:{turn_id}") +} + +fn replan_settlement_effect_id( + goal_id: &str, + agent_id: &str, + obligation_id: &str, + turn_id: &str, +) -> String { + format!("{goal_id}:{agent_id}:autonomous_replan:{obligation_id}:{turn_id}") +} + +fn is_legacy_turn_key(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) +} + +fn project_legacy_settlement( + request: &loopx_contract::LoopxCliSettleTurnRequest, + snapshot: &loopx_contract::LoopxCliGoalSnapshot, + receipt: &Value, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + let receipt_id = receipt + .get("receipt_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "matching LoopX settlement receipt has no receipt_id", + false, + ) + })? + .to_string(); + let validation_succeeded = receipt + .get("validation_succeeded") + .and_then(Value::as_bool) + .or_else(|| { + receipt + .pointer("/validation/succeeded") + .and_then(Value::as_bool) + }) + .unwrap_or(false); + Ok(loopx_contract::LoopxCliSettleTurnResult { + goal_id: request.goal_id.clone(), + turn_id: request.turn_id.clone(), + receipt_id, + status: if !validation_succeeded { + loopx_contract::LoopxCliSettlementStatus::NoDurableProgress + } else if snapshot.state == loopx_contract::LoopxCliGoalState::Completed { + loopx_contract::LoopxCliSettlementStatus::GoalCompleted + } else { + loopx_contract::LoopxCliSettlementStatus::AlreadySettled + }, + before_revision: request.expected_durable_revision.clone(), + after_revision: snapshot.durable_revision.clone(), + validation_succeeded, + scheduler_hint_ms: snapshot.scheduler_hint_ms, + }) +} + +fn agent_shell_command(command: &VerifiedLoopxCommand) -> String { + let display = command.executable.to_string_lossy().replace('"', "\\\""); + let arguments = command + .prefix_args + .iter() + .map(|argument| agent_shell_value(&argument.to_string_lossy())) + .collect::>() + .join(" "); + if cfg!(windows) { + let environment = command + .environment + .iter() + .map(|(key, value)| { + format!( + "$env:{}={}; ", + key.to_string_lossy(), + agent_shell_value(&value.to_string_lossy()) + ) + }) + .collect::(); + format!( + "{environment}& \"{display}\"{}", + if arguments.is_empty() { + String::new() + } else { + format!(" {arguments}") + } + ) + } else { + let environment = command + .environment + .iter() + .map(|(key, value)| { + format!( + "{}={} ", + key.to_string_lossy(), + agent_shell_value(&value.to_string_lossy()) + ) + }) + .collect::(); + format!( + "{environment}'{}'{}", + display.replace('\'', "'\\''"), + if arguments.is_empty() { + String::new() + } else { + format!(" {arguments}") + } + ) + } +} + +fn agent_shell_value(value: &str) -> String { + if cfg!(windows) { + format!("\"{}\"", value.replace('"', "\\\"")) + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + +const AGENT_SUMMARY_CONTRACT: &str = r#"End your final response with a fenced block holding exactly this JSON shape; it is the human-readable report of this segment. Fill human-facing values as plain self-contained sentences: never use internal codes or identifiers there (candidate numbers like C-1, todo ids, turn keys, effect ids, contract field names). Approval and gate state never goes into this block; the host approval card is the only surface for it. Omit optional fields that do not apply: + +```loopx_summary_v1 +{ + "issue_verdict": "needs_fix | already_fixed_upstream | wont_fix | needs_info", + "fixed_by": "link to the upstream fix (required only when already_fixed_upstream)", + "wont_fix_reason": "duplicate_of:<#issue> | by_design | invalid (required only when wont_fix)", + "missing_info": ["what is missing"], + "reproduction": "reproduced | not_reproduced | not_applicable", + "reproduction_evidence": "link or path (required only when reproduced)", + "segment_kind": "evidence | route_decision | implementation | validation | delivery", + "completed": ["up to three plain sentences, each self-contained"], + "artifacts": ["links to evidence or outputs"], + "decision": { + "route": "the chosen approach as one complete plain sentence (no codes)", + "reason": "why this one", + "rejected": [ { "route": "...", "why": "..." } ] + }, + "blockers": ["only obstacles you observed; approval matters never go here"], + "next_step": "one plain sentence that follows from the decision and completed items" +} +``` + +Conditional rules: already_fixed_upstream requires fixed_by; wont_fix requires wont_fix_reason; needs_info requires a non-empty missing_info; reproduced requires reproduction_evidence. next_step must not introduce facts that are not in decision or completed. When a user gate is pending, skip decision and next_step — the host approval card carries it."#; + +fn render_agent_reentry_instruction( + packet: &Value, + command: &VerifiedLoopxCommand, + registry_path: &str, + turn_id: &str, + binding: Option<&SettlementBinding>, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + let envelope = turn_envelope(packet, operation_id)?; + let contract = serde_json::json!({ + "schema_version": "bitfun_loopx_agent_turn_v0", + "goal_id": envelope.get("goal_id"), + "agent_id": envelope.get("agent_id"), + "turn_id": turn_id, + "decision": envelope.get("decision"), + "state": envelope.get("state"), + "effective_action": envelope.get("effective_action"), + "action": envelope.get("action"), + "user": envelope.get("user"), + "required_reads": envelope.get("required_reads"), + "replan_action_packet": envelope.get("replan_action_packet"), + "boundary": envelope.get("boundary"), + "execution_policy": envelope.get("execution_policy"), + "writeback": envelope.get("writeback"), + "contract_capsule": envelope.get("contract_capsule"), + "response_plan": envelope.get("response_plan"), + "task_orchestration_contract": envelope.get("task_orchestration_contract"), + "detail_ref": envelope.get("detail_ref"), + }); + let contract_json = serde_json::to_string_pretty(&contract).map_err(|error| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + error.to_string(), + false, + ) + })?; + let cli_prefix = format!( + "{} --format json --registry {}", + agent_shell_command(command), + agent_shell_value(registry_path) + ); + let binding_flags = match binding { + Some(SettlementBinding::Todo { todo_id }) => format!( + "--todo-id {} --turn-instance-id {} --agent-id {}", + agent_shell_value(todo_id), + agent_shell_value(turn_id), + agent_shell_value( + envelope + .get("agent_id") + .and_then(Value::as_str) + .unwrap_or_default() + ) + ), + Some(SettlementBinding::AutonomousReplan { obligation_id }) => format!( + "--replan-obligation-id {} --turn-instance-id {} --autonomous-replan-recorded --agent-id {}", + agent_shell_value(obligation_id), + agent_shell_value(turn_id), + agent_shell_value( + envelope + .get("agent_id") + .and_then(Value::as_str) + .unwrap_or_default() + ) + ), + None => format!("--turn-instance-id {}", agent_shell_value(turn_id)), + }; + // The typed quota guard resolves the scheduler execution context from the + // invocation flags. Without an explicit declaration the context is missing + // (`repair_scheduler_execution_context`, "no quota spend for scheduler + // context repair") and every spend is rejected fail-closed even when the + // durable writeback validated. The host's own quota guard declares + // `--runtime-profile outer_controller` for the same goal; the agent's spend + // must declare exactly the same boundary. + let goal_id_arg = format!( + "--goal-id {}", + agent_shell_value( + envelope + .get("goal_id") + .and_then(Value::as_str) + .unwrap_or_default() + ) + ); + let agent_id_arg = format!( + "--agent-id {}", + agent_shell_value( + envelope + .get("agent_id") + .and_then(Value::as_str) + .unwrap_or_default() + ) + ); + let spend_command = match binding { + Some(SettlementBinding::Todo { todo_id }) => format!( + "{cli_prefix} quota spend-slot {goal_id_arg} --slots 1 --source heartbeat --execute --todo-id {} --turn-instance-id {} {agent_id_arg} --runtime-profile outer_controller", + agent_shell_value(todo_id), + agent_shell_value(turn_id), + ), + Some(SettlementBinding::AutonomousReplan { obligation_id }) => format!( + "{cli_prefix} quota spend-slot {goal_id_arg} --slots 1 --source heartbeat --execute --replan-obligation-id {} --turn-instance-id {} {agent_id_arg} --runtime-profile outer_controller", + agent_shell_value(obligation_id), + agent_shell_value(turn_id), + ), + None => String::new(), + }; + let spend_instruction = if spend_command.is_empty() { + "This turn has no settlement binding; do not spend quota.".to_string() + } else { + format!( + "After the writeback validates, run this exact quota spend command once:\n`{spend_command}`\nRun it verbatim: do not add, remove, or reorder flags, and do not substitute the todo or turn ids. If it returns a typed rejection naming `repair_scheduler_execution_context` or an advanced guard, stop and report the rejection verbatim; do not retry with modified arguments." + ) + }; + // Replan turns have no selected todo: the replan obligation itself is the + // work item, so the generic "claim the selected todo" clause would point + // the agent at a todo that does not exist. + let work_clause = match binding { + Some(SettlementBinding::AutonomousReplan { .. }) => { + "This turn is an autonomous replan turn: no todo is selected and you must not invent a todo claim. Apply the `replan_action_packet` from the contract: first re-read the durable goal state and current evidence, then record exactly one required semantic outcome through the refresh-state writeback flags below — a concrete runnable successor todo only when an executable target is known, otherwise a typed terminal outcome (for example a coverage-backed `no_followup` with the goal vision closed) or a new concrete blocker with evidence. The replan ACK must carry a matching typed `--repair-delta-kind` (for example `no_followup`, `blocker`, `successor_or_supersede`, `goal_vision_patch`, or `exploration_exhausted`): an ACK without a delta is stored as a no-op and does not clear the obligation.".to_string() + } + _ => "Claim the selected executable todo before write-capable work. Execute only the selected action in the current worktree. Then use the LoopX CLI prefix to complete, update, block, or defer the selected todo and create a successor only when concrete follow-up remains.".to_string(), + }; + Ok(format!( + "You are the BitFun Agent executing one bounded LoopX-controlled work segment.\n\nLoopX CLI prefix for this task: `{cli_prefix}`\nThe BitFun runner already evaluated this turn's fresh quota guard. Do not run another scheduler or create another worktree.\n\nFollow the JSON contract below as the source of truth:\n\n{contract_json}\n\n\n{work_clause} Validate the real postcondition with tools; a prose claim is not evidence. Run the contract's refresh-state writeback with these exact identity flags: `{binding_flags}`. {spend_instruction} BitFun owns wake, cancellation, UI projection, and scheduler application.\n\n{AGENT_SUMMARY_CONTRACT}" + )) +} + +async fn run_port_command( + adapter: &LoopxCliProcessAdapter, + context: &loopx_contract::LoopxCliGoalContext, + args: Vec, + observer: &dyn LoopxProcessObserver, +) -> loopx_contract::LoopxCliResult { + run_port_command_raw(adapter, context, args, observer) + .await + .map_err(|error| raw_port_error_into(error, &context.call.operation_id)) +} + +/// Error surface of [`run_port_command_raw`]: either an already-typed port +/// error (preflight/deadline) or the raw adapter error, which keeps the CLI's +/// typed `ok:false` payload attached to a non-zero exit. +enum RawPortError { + Port(loopx_contract::LoopxCliError), + Adapter(LoopxCliAdapterError), +} + +fn raw_port_error_into(error: RawPortError, operation_id: &str) -> loopx_contract::LoopxCliError { + match error { + RawPortError::Port(error) => error, + RawPortError::Adapter(error) => map_port_error(error, operation_id), + } +} + +/// Raw-error variant of [`run_port_command`]: callers that need to inspect the +/// underlying [`LoopxCliAdapterError`] (for example to salvage a typed +/// `ok:false` payload from a non-zero exit) use this instead. +async fn run_port_command_raw( + adapter: &LoopxCliProcessAdapter, + context: &loopx_contract::LoopxCliGoalContext, + args: Vec, + observer: &dyn LoopxProcessObserver, +) -> Result { + let operation_id = &context.call.operation_id; + let deadline = effective_deadline( + context.call.deadline_at, + adapter.config.command_deadline, + operation_id, + ) + .map_err(RawPortError::Port)?; + adapter + .run_json_command( + operation_id, + Path::new(&context.registry_path), + Some(Path::new(&context.worktree_path)), + args, + deadline, + observer, + ) + .await + .map_err(RawPortError::Adapter) +} + +/// Runs a `turn plan` inspection and projects its goal snapshot. When the +/// pinned CLI exits 1 with the plan-exhausted replan-lineage contract error, +/// the typed payload it printed is salvaged into the equivalent read-only +/// `RunNow`-without-todo snapshot (with the open replan obligation id) +/// instead of failing the operation: the host then drives one autonomous +/// replan turn bound to that obligation, or parks the task when no +/// obligation remains. Any other failure maps to the standard port error. +async fn inspect_goal_snapshot( + adapter: &LoopxCliProcessAdapter, + goal_id: &str, + context: &loopx_contract::LoopxCliGoalContext, + args: Vec, + observer: &dyn LoopxProcessObserver, +) -> loopx_contract::LoopxCliResult { + let operation_id = &context.call.operation_id; + match run_port_command_raw(adapter, context, args, observer).await { + Ok(output) => project_goal_snapshot(goal_id, &output.payload, operation_id), + Err(RawPortError::Port(error)) => Err(error), + Err(RawPortError::Adapter(error)) => { + let Some(payload) = salvage_replan_lineage_payload(&error) else { + return Err(map_port_error(error, operation_id)); + }; + log::warn!( + "LoopX turn plan projected the plan-exhausted replan frontier without a todo lineage; salvaging the typed payload: operation_id={} error=\"{}\"", + operation_id, + LOOPX_REPLAN_LINEAGE_ERROR + ); + salvaged_replan_lineage_snapshot(goal_id, &payload, operation_id) + .map(|(snapshot, _)| snapshot) + .ok_or_else(|| map_port_error(error, operation_id)) + } + } +} + +async fn run_idempotent_global_command( + adapter: &LoopxCliProcessAdapter, + operation_id: &str, + args: Vec, + missing_error_prefix: &str, + deadline: Duration, + observer: &dyn LoopxProcessObserver, +) -> loopx_contract::LoopxCliResult> { + match adapter + .run_global_json_command(operation_id, args, deadline, observer) + .await + { + Ok(output) => Ok(Some(output)), + Err(error) => { + if process_error_json(&error) + .and_then(|payload| { + payload + .get("error") + .and_then(Value::as_str) + .map(str::to_string) + }) + .is_some_and(|message| message.starts_with(missing_error_prefix)) + { + return Ok(None); + } + Err(map_port_error(error, operation_id)) + } + } +} + +fn process_error_json(error: &LoopxCliAdapterError) -> Option { + let LoopxCliAdapterError::Process(LoopxProcessError::Exited { stdout_tail, .. }) = error else { + return None; + }; + serde_json::from_str(&stdout_tail.join("\n")).ok() +} + +/// The pinned LoopX v0.5.1 CLI renders its plan-exhausted replan frontier +/// (every todo done or blocked, an open autonomous replan obligation, and no +/// selected todo) as `route.kind=contract_error` on `turn plan`: host-bound +/// routes demand a goal/agent/todo/action-hash lineage that a todo-less +/// frontier cannot provide, so the CLI prints the typed `ok:false` payload and +/// exits 1. This is the exact pinned-string marker for that projection. +const LOOPX_REPLAN_LINEAGE_ERROR: &str = + "host-bound routes require goal, agent, todo, and action-hash lineage"; + +/// Salvages the typed `turn plan` payload from the pinned CLI's +/// replan-without-todo lineage contract error. Returns `None` for any other +/// process failure so unrelated errors still surface unchanged. The caller +/// must treat the result as read-only: the CLI already refused to execute, so +/// nothing here writes goal state or fabricates a settlement. +fn salvage_replan_lineage_payload(error: &LoopxCliAdapterError) -> Option { + let LoopxCliAdapterError::Process(LoopxProcessError::Exited { + payload: Some(payload), + .. + }) = error + else { + return None; + }; + if payload.get("error").and_then(Value::as_str) != Some(LOOPX_REPLAN_LINEAGE_ERROR) { + return None; + } + let envelope = payload.get("turn_envelope")?; + // Only the plan-exhausted replan frontier is salvaged: the envelope must + // itself assert `should_run` with an open replan obligation and no + // selected todo. Any other host-route lineage failure keeps its error. + if envelope.get("should_run").and_then(Value::as_bool) != Some(true) { + return None; + } + let selected_todo = envelope + .pointer("/action/selected_todo/todo_id") + .and_then(Value::as_str) + .unwrap_or_default(); + if !selected_todo.is_empty() { + return None; + } + let obligation = envelope + .pointer("/replan_action_packet/obligation_id") + .and_then(Value::as_str) + .unwrap_or_default(); + if obligation.is_empty() { + return None; + } + Some(payload.clone()) +} + +/// Projects the salvaged replan-lineage payload into the same read-only goal +/// snapshot vocabulary `turn plan` uses on success. The frontier is actionable +/// only through an autonomous replan, so the snapshot keeps `RunNow` with no +/// open todo, no waiting user decision, no selected todo, and the open replan +/// obligation id: the host's frontier handling then drives one bounded +/// autonomous replan turn bound to that obligation. +fn salvaged_replan_lineage_snapshot( + goal_id: &str, + payload: &Value, + operation_id: &str, +) -> Option<(loopx_contract::LoopxCliGoalSnapshot, String)> { + let envelope = payload.get("turn_envelope")?; + let durable_revision = extract_durable_revision(payload, operation_id).ok()?; + let open_todo_count = envelope + .get("open_count") + .and_then(Value::as_u64) + .unwrap_or_default(); + let waiting_user_todo_count = envelope + .pointer("/user/open_count") + .and_then(Value::as_u64) + .unwrap_or_default(); + let obligation_id = envelope + .pointer("/replan_action_packet/obligation_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty())? + .to_string(); + Some(( + loopx_contract::LoopxCliGoalSnapshot { + goal_id: goal_id.to_string(), + state: loopx_contract::LoopxCliGoalState::Active, + durable_revision, + run_decision: loopx_contract::LoopxCliRunDecision::RunNow, + scheduler_hint_ms: scheduler_hint_ms(payload), + open_todo_count: open_todo_count.try_into().unwrap_or(u32::MAX), + waiting_user_todo_count: waiting_user_todo_count.try_into().unwrap_or(u32::MAX), + pending_user_gate: None, + selected_todo: None, + pending_replan_obligation_id: Some(obligation_id.clone()), + envelope_over_budget: envelope + .pointer("/compaction/within_budget") + .and_then(Value::as_bool) + == Some(false), + }, + obligation_id, + )) +} + +/// Maps the host-resolved remote state onto the state vocabulary LoopX's +/// metadata projection accepts (`open` | `closed` | `unknown`). LoopX has no +/// `merged` metadata state; a merged PR is already terminal, so `closed` is +/// the truthful projection. +fn metadata_state(state: loopx_contract::LoopxRemoteItemState) -> &'static str { + use loopx_contract::LoopxRemoteItemState; + match state { + LoopxRemoteItemState::Open => "open", + LoopxRemoteItemState::Closed | LoopxRemoteItemState::Merged => "closed", + LoopxRemoteItemState::Unknown => "unknown", + } +} + +/// Builds the goal objective from the host-resolved title. The workflow-plan +/// packet carries no objective field, and the issue title is more meaningful +/// for goal surfaces than the bare canonical URL. +fn compact_objective(item: &loopx_contract::LoopxIssueKey, title: &str) -> String { + const MAX_TITLE_CHARS: usize = 120; + let title = title.trim(); + if title.is_empty() { + return format!("Fix {}", item.canonical_url()); + } + let mut bounded: String = title.chars().take(MAX_TITLE_CHARS).collect(); + if title.chars().count() > MAX_TITLE_CHARS { + bounded.push_str("..."); + } + format!("Fix #{}: {}", item.number, bounded) +} + +fn plan_item_args(request: &loopx_contract::LoopxCliPlanItemRequest) -> Vec { + let kind = match request.item.kind { + loopx_contract::LoopxItemKind::Issue => "issue", + loopx_contract::LoopxItemKind::PullRequest => "pull_request", + }; + // The intake adapter already resolved this metadata from the GitHub API; + // never fabricate an open state or drop labels here, because the plan + // packet feeds candidate admission, intake classification, and dedup. + let metadata = serde_json::json!({ + "number": request.item.number, + "state": metadata_state(request.state), + "title": request.title, + "labels": request.labels, + "kind": kind, + }) + .to_string(); + [ + "issue-fix".to_string(), + "workflow-plan".to_string(), + "--url".to_string(), + request.item.canonical_url(), + "--repo-path".to_string(), + request.context.worktree_path.clone(), + "--metadata-json".to_string(), + metadata, + ] + .into_iter() + .map(OsString::from) + .collect() +} + +fn bootstrap_args(request: &loopx_contract::LoopxCliCreateGoalRequest) -> Vec { + // LoopX bootstrap defaults this legacy, Codex-named option to `ask`. + // Explicitly disable it because BitFun owns wakeups through its generic + // outer controller; this does not enable or emulate a Codex integration. + let mut args = vec![ + "bootstrap".to_string(), + "--project".to_string(), + request.context.worktree_path.clone(), + "--goal-id".to_string(), + request.goal_id.clone(), + "--objective".to_string(), + request.intake.objective.clone(), + "--adapter-kind".to_string(), + "read_only_project_map_v0".to_string(), + "--adapter-status".to_string(), + "connected-read-only".to_string(), + "--no-onboarding-scan".to_string(), + "--codex-app-heartbeat".to_string(), + "no".to_string(), + ]; + if request + .granted_scopes + .contains(&loopx_contract::LoopxPermissionScope::WorkspaceWrite) + { + args.extend(["--write-scope".to_string(), "write".to_string()]); + } + args.into_iter().map(OsString::from).collect() +} + +fn register_agent_args(request: &loopx_contract::LoopxCliCreateGoalRequest) -> Vec { + [ + "register-agent", + "--goal-id", + request.goal_id.as_str(), + "--agent-id", + request.agent_id.as_str(), + "--execute", + ] + .into_iter() + .map(OsString::from) + .collect() +} + +fn list_todos_args(goal_id: &str) -> Vec { + ["todo", "list", "--goal-id", goal_id] + .into_iter() + .map(OsString::from) + .collect() +} + +fn todo_matches(existing: &Value, planned: &loopx_contract::LoopxCliTodoPlan) -> bool { + existing.get("role").and_then(Value::as_str) == Some(planned.role.as_str()) + && existing.get("task_class").and_then(Value::as_str) == Some(planned.task_class.as_str()) + && existing.get("text").and_then(Value::as_str) == Some(planned.text.as_str()) + && existing.get("action_kind").and_then(Value::as_str) == planned.action_kind.as_deref() +} + +fn add_todo_args( + request: &loopx_contract::LoopxCliCreateGoalRequest, + todo: &loopx_contract::LoopxCliTodoPlan, +) -> loopx_contract::LoopxCliResult> { + let operation_id = &request.context.call.operation_id; + if !matches!(todo.role.as_str(), "agent" | "user") { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "todo role must be agent or user", + false, + )); + } + if !matches!( + todo.task_class.as_str(), + "advancement_task" | "continuous_monitor" | "user_gate" | "user_action" | "blocker" + ) { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "todo task_class is not supported by LoopX v0.5.1", + false, + )); + } + validate_nonempty("todo.text", &todo.text, operation_id)?; + let repository = &request.intake.item.repository; + let mut args = vec![ + "todo".to_string(), + "add".to_string(), + "--goal-id".to_string(), + request.goal_id.clone(), + "--role".to_string(), + todo.role.clone(), + "--task-class".to_string(), + todo.task_class.clone(), + "--text".to_string(), + todo.text.clone(), + ]; + if let Some(action_kind) = &todo.action_kind { + if !is_public_token(action_kind) { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "todo action_kind must be a bounded public-safe token", + false, + )); + } + args.extend(["--action-kind".to_string(), action_kind.clone()]); + } + if todo.role == "agent" { + args.extend(["--claimed-by".to_string(), request.agent_id.clone()]); + args.extend([ + "--task-repository".to_string(), + format!( + "git:{}/{}/{}", + repository.host, repository.owner, repository.repository + ) + .to_lowercase(), + ]); + } else { + args.extend(["--agent-id".to_string(), request.agent_id.clone()]); + } + Ok(args.into_iter().map(OsString::from).collect()) +} + +fn turn_plan_args(goal_id: &str, agent_id: &str, turn_id: Option<&str>) -> Vec { + let mut args = vec![ + "turn".to_string(), + "plan".to_string(), + "--goal-id".to_string(), + goal_id.to_string(), + "--agent-id".to_string(), + agent_id.to_string(), + "--host".to_string(), + "generic-cli".to_string(), + "--execution-mode".to_string(), + "isolated-headless".to_string(), + "--scheduler-owner".to_string(), + "outer_controller".to_string(), + "--include-transaction-detail".to_string(), + ]; + if let Some(turn_id) = turn_id { + args.extend(["--turn-instance-id".to_string(), turn_id.to_string()]); + } + args.into_iter().map(OsString::from).collect() +} + +fn quota_guard_args( + goal_id: &str, + agent_id: &str, + binding: Option<&SettlementBinding>, + turn_id: &str, +) -> Vec { + let mut args = vec![ + OsString::from("quota"), + OsString::from("should-run"), + OsString::from("--goal-id"), + OsString::from(goal_id), + OsString::from("--agent-id"), + OsString::from(agent_id), + OsString::from("--runtime-profile"), + OsString::from("outer_controller"), + OsString::from("--turn-instance-id"), + OsString::from(turn_id), + ]; + if let Some(SettlementBinding::Todo { todo_id }) = binding { + args.extend([OsString::from("--todo-id"), OsString::from(todo_id)]); + } + args +} + +fn extend_available_capability_args( + args: &mut Vec, + capabilities: &[String], + operation_id: &str, +) -> loopx_contract::LoopxCliResult<()> { + let mut unique = BTreeSet::new(); + for capability in capabilities { + let capability = capability.trim(); + if !is_public_token(capability) { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "available capability must be a bounded public-safe token", + false, + )); + } + if !unique.insert(capability) { + continue; + } + args.push(OsString::from("--available-capability")); + args.push(OsString::from(capability)); + } + Ok(()) +} + +fn settlement_history_args(goal_id: &str) -> Vec { + [ + "history", + "--goal-id", + goal_id, + "--limit", + SETTLEMENT_HISTORY_LIMIT, + ] + .into_iter() + .map(OsString::from) + .collect() +} + +fn retire_global_goal_args(goal_id: &str) -> Vec { + ["retire-global-goal", "--goal-id", goal_id, "--execute"] + .into_iter() + .map(OsString::from) + .collect() +} + +fn archive_runtime_args(goal_id: &str) -> Vec { + ["archive-runtime", "--goal-id", goal_id, "--execute"] + .into_iter() + .map(OsString::from) + .collect() +} + +#[cfg(test)] +mod custom_runner_contract_tests { + use super::{ + answer_gate_args, compact_objective, loopx_contract, matching_durable_progress, + metadata_state, plan_item_args, planned_settlement_token, project_goal_snapshot, + quota_guard_args, render_agent_reentry_instruction, LoopxCommandSource, SettlementBinding, + VerifiedLoopxCommand, + }; + use openbitfun_product_domains::miniapp::loopx::{ + LoopxCliPlanItemRequest, LoopxIssueKey, LoopxItemKind, LoopxRemoteItemState, + LoopxRepositoryKey, + }; + use serde_json::json; + use std::collections::BTreeMap; + use std::ffi::OsString; + use std::path::PathBuf; + + fn issue_key(number: u64) -> LoopxIssueKey { + LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number, + } + } + + #[test] + fn plan_item_args_pass_resolved_state_and_labels() { + let request = LoopxCliPlanItemRequest { + item: issue_key(42), + title: "Search returns stale results".to_string(), + state: LoopxRemoteItemState::Closed, + labels: vec!["bug".to_string(), "needs-repro".to_string()], + ..Default::default() + }; + let metadata = plan_item_args(&request) + .windows(2) + .find(|pair| pair[0] == OsString::from("--metadata-json")) + .map(|pair| pair[1].to_string_lossy().into_owned()) + .expect("metadata json argument"); + let payload: serde_json::Value = serde_json::from_str(&metadata).unwrap(); + assert_eq!(payload["state"], "closed"); + assert_eq!(payload["labels"], json!(["bug", "needs-repro"])); + assert_eq!(payload["number"], 42); + assert_eq!(payload["kind"], "issue"); + } + + #[test] + fn answer_gate_args_mark_the_decision_as_an_explicit_effect() { + let request = loopx_contract::LoopxCliAnswerGateRequest { + context: loopx_contract::LoopxCliGoalContext::default(), + goal_id: "goal-1".to_string(), + agent_id: "agent-1".to_string(), + gate_id: "todo-user-gate".to_string(), + decision: loopx_contract::LoopxCliGateDecision::Approve, + note: None, + granted_scope: None, + }; + + let args = answer_gate_args(&request).expect("gate args"); + + assert!(args.contains(&OsString::from("--execute"))); + } + + #[test] + fn metadata_state_maps_merged_and_unknown_truthfully() { + assert_eq!(metadata_state(LoopxRemoteItemState::Open), "open"); + assert_eq!(metadata_state(LoopxRemoteItemState::Closed), "closed"); + // LoopX has no merged metadata state; merged is already terminal. + assert_eq!(metadata_state(LoopxRemoteItemState::Merged), "closed"); + assert_eq!(metadata_state(LoopxRemoteItemState::Unknown), "unknown"); + } + + #[test] + fn compact_objective_uses_the_resolved_title_and_bounds_length() { + let item = issue_key(42); + assert_eq!( + compact_objective(&item, " Crash on empty input "), + "Fix #42: Crash on empty input" + ); + assert_eq!( + compact_objective(&item, ""), + format!("Fix {}", item.canonical_url()) + ); + let long = format!("{}-tail", "x".repeat(200)); + let objective = compact_objective(&item, &long); + assert!(objective.starts_with("Fix #42: ")); + assert!(objective.chars().count() <= "Fix #42: ".len() + 123); + assert!(objective.ends_with("...")); + } + + #[test] + fn custom_runner_instruction_projects_only_the_fresh_turn_contract() { + let command = VerifiedLoopxCommand { + executable: PathBuf::from("loopx"), + prefix_args: Vec::new(), + environment: BTreeMap::new(), + source: LoopxCommandSource::FixedSystemCommand, + version: "0.5.1".to_string(), + bundle_manifest_schema: None, + command_reference_schema: "loopx_command_reference_v0".to_string(), + sha256: None, + }; + let packet = json!({ + "schema_version": "loopx_turn_envelope_v0", + "goal_id": "goal-1", + "agent_id": "agent-1", + "action": { + "recommended_action": "Fix the selected issue.", + "selected_todo": {"todo_id": "todo-1"} + }, + "user": { + "action_required": true, + "actions": [{"todo_id": "user-1", "text": "Approve publication"}] + }, + "required_reads": [], + "boundary": {"rule": "stay_in_scope_or_stop"}, + "execution_policy": {"normal_delivery_allowed": true}, + "writeback": {"spend_after_validation": true}, + "contract_capsule": {"schema_version": "loopx_contract_capsule_v0"} + }); + let binding = SettlementBinding::Todo { + todo_id: "todo-1".to_string(), + }; + let instruction = render_agent_reentry_instruction( + &packet, + &command, + ".loopx/registry.json", + "turn-1", + Some(&binding), + "build-turn", + ) + .expect("custom runner instruction"); + + assert!(instruction.contains("BitFun Agent executing one bounded")); + assert!(instruction.contains("Fix the selected issue.")); + assert!(instruction.contains(".loopx/registry.json")); + assert!(instruction.contains("turn-1")); + assert!(instruction.contains("--todo-id")); + assert!(instruction.contains("Claim the selected executable todo")); + assert!(instruction.contains("Approve publication")); + assert!(instruction.contains("a prose claim is not evidence")); + // The typed quota spend command must carry the scheduler execution + // context declaration; without it every spend is rejected fail-closed + // (`repair_scheduler_execution_context`). + assert!(instruction.contains("quota spend-slot")); + assert!(instruction.contains("--goal-id")); + assert!(instruction.contains("goal-1")); + assert!(instruction.contains("--source heartbeat --execute")); + assert!(instruction.contains("--runtime-profile outer_controller")); + assert!(instruction.contains("--turn-instance-id")); + + let guard = quota_guard_args("goal-1", "agent-1", Some(&binding), "turn-1"); + assert!(guard.windows(2).any(|pair| pair == ["--todo-id", "todo-1"])); + } + + #[test] + fn selected_todo_builds_the_exact_settlement_effect_identity() { + let token = planned_settlement_token( + &json!({ + "route": {"selected_todo": {"todo_id": "todo-1"}}, + "transaction": {"turn_key": "sha256:legacy"} + }), + "goal-1", + "agent-1", + "turn-1", + "build-turn", + ) + .unwrap(); + + assert_eq!(token, "goal-1:agent-1:todo-1:turn-1"); + } + + #[test] + fn replan_route_builds_the_autonomous_replan_effect_identity() { + let token = planned_settlement_token( + &json!({ + "route": { + "kind": "replan_required", + "selected_todo": {"todo_id": "todo-1"} + }, + "turn_envelope": { + "replan_action_packet": {"obligation_id": "replan-1"} + }, + "transaction": {"turn_key": "sha256:legacy"} + }), + "goal-1", + "agent-1", + "turn-1", + "build-turn", + ) + .unwrap(); + + assert_eq!(token, "goal-1:agent-1:autonomous_replan:replan-1:turn-1"); + } + + #[test] + fn durable_settlement_requires_matching_advanced_progress_and_quota_spend() { + let effect_id = "goal-1:agent-1:todo-1:turn-1"; + let identity = json!({ + "schema_version": "quota_settlement_identity_v0", + "effect_id": effect_id, + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1" + }); + let payload = json!({ + "goals": [{ + "id": "goal-1", + "latest_runs": [ + { + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1", + "classification": "validated_progress", + "progress_observation": { + "schema_version": "typed_progress_observation_v0", + "result_class": "advanced", + "work_item_id": "todo-1" + }, + "settlement_identity": identity.clone() + }, + { + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1", + "classification": "quota_slot_spent", + "settlement_identity": identity + } + ] + }] + }); + + let evidence = matching_durable_progress( + &payload, + "goal-1", + "agent-1", + "turn-1", + effect_id, + "settle-turn", + ) + .unwrap() + .expect("settlement evidence"); + assert_eq!(evidence.effect_id, effect_id); + assert!(evidence.quota_spent); + } + + #[test] + fn durable_progress_without_quota_remains_unsettled() { + let effect_id = "goal-1:agent-1:todo-1:turn-1"; + let payload = json!({ + "goals": [{ + "id": "goal-1", + "latest_runs": [{ + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1", + "classification": "validated_progress", + "progress_observation": { + "schema_version": "typed_progress_observation_v0", + "result_class": "advanced", + "work_item_id": "todo-1" + }, + "settlement_identity": { + "schema_version": "quota_settlement_identity_v0", + "effect_id": effect_id, + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1" + } + }] + }] + }); + + let evidence = matching_durable_progress( + &payload, + "goal-1", + "agent-1", + "turn-1", + effect_id, + "settle-turn", + ) + .unwrap() + .expect("validated progress"); + assert!(!evidence.quota_spent); + assert_eq!( + evidence.binding, + SettlementBinding::Todo { + todo_id: "todo-1".to_string() + } + ); + } + + #[test] + fn autonomous_replan_progress_uses_the_v1_settlement_binding() { + let effect_id = "goal-1:agent-1:autonomous_replan:replan-1:turn-1"; + let payload = json!({ + "goals": [{ + "id": "goal-1", + "latest_runs": [{ + "goal_id": "goal-1", + "agent_id": "agent-1", + "turn_instance_id": "turn-1", + "replan_obligation_id": "replan-1", + "classification": "state_projection_repair", + "delivery_outcome": "outcome_progress", + "progress_observation": { + "schema_version": "typed_progress_observation_v0", + "result_class": "advanced" + }, + "settlement_identity": { + "schema_version": "quota_settlement_identity_v1", + "effect_id": effect_id, + "goal_id": "goal-1", + "agent_id": "agent-1", + "turn_instance_id": "turn-1", + "binding_kind": "autonomous_replan", + "binding_id": "replan-1", + "replan_obligation_id": "replan-1" + } + }] + }] + }); + + let evidence = matching_durable_progress( + &payload, + "goal-1", + "agent-1", + "turn-1", + "sha256:legacy", + "settle-turn", + ) + .unwrap() + .expect("replan progress"); + assert_eq!(evidence.effect_id, effect_id); + assert_eq!( + evidence.binding, + SettlementBinding::AutonomousReplan { + obligation_id: "replan-1".to_string() + } + ); + assert!(!evidence.quota_spent); + } + + #[test] + fn monitor_todo_projection_is_bounded_and_non_authoritative() { + let snapshot = project_goal_snapshot( + "goal-1", + &json!({ + "ok": true, + "schema_version": "loopx_turn_plan_v0", + "turn_envelope": { + "should_run": false, + "state": "monitor_wait", + "effective_action": "monitor_wait", + "open_count": 1, + "action": { + "recommended_action": "Wait for CI on the published PR; replan when a maintainer requests changes.", + "selected_todo": { + "todo_id": "todo-monitor-1", + "task_class": "continuous_monitor", + "action_kind": "issue_fix_pr_state_checks_pending_monitor", + "target_key": "issue_fix_pr_state_checks_pending", + "claimed_by": "bitfun-loopx", + "next_due_at": "2026-09-03T12:00:00Z" + } + } + } + }), + "inspect-goal", + ) + .unwrap(); + + assert_eq!( + snapshot.run_decision, + loopx_contract::LoopxCliRunDecision::Wait + ); + let todo = snapshot + .selected_todo + .expect("monitor todo projection from the envelope"); + assert_eq!(todo.todo_id, "todo-monitor-1"); + assert_eq!(todo.task_class, "continuous_monitor"); + assert_eq!( + todo.action_kind, + "issue_fix_pr_state_checks_pending_monitor" + ); + assert_eq!(todo.next_due_at.as_deref(), Some("2026-09-03T12:00:00Z")); + assert!(todo.recommended_action.contains("Wait for CI")); + } + + #[test] + fn selected_todo_without_an_id_is_dropped() { + let snapshot = project_goal_snapshot( + "goal-1", + &json!({ + "ok": true, + "schema_version": "loopx_turn_plan_v0", + "turn_envelope": { + "should_run": false, + "state": "eligible", + "action": {"selected_todo": {"task_class": "advancement_task"}} + } + }), + "inspect-goal", + ) + .unwrap(); + assert!(snapshot.selected_todo.is_none()); + } + + #[test] + fn terminal_no_followup_is_a_completed_goal() { + let snapshot = project_goal_snapshot( + "goal-1", + &json!({ + "ok": true, + "schema_version": "loopx_turn_plan_v0", + "turn_envelope": { + "should_run": false, + "state": "terminal_no_followup", + "effective_action": "terminal_no_followup", + "action_signature": { + "source_hash": "sha256:terminal" + } + } + }), + "inspect-goal", + ) + .unwrap(); + + assert_eq!( + snapshot.run_decision, + loopx_contract::LoopxCliRunDecision::Complete + ); + assert_eq!(snapshot.state, loopx_contract::LoopxCliGoalState::Completed); + } + + #[test] + fn runnable_agent_work_takes_precedence_over_a_concurrent_user_action() { + let snapshot = project_goal_snapshot( + "goal-1", + &json!({ + "ok": true, + "schema_version": "loopx_turn_plan_v0", + "turn_envelope": { + "should_run": true, + "state": "eligible", + "effective_action": "run_selected_todo", + "open_count": 2, + "user": { + "action_required": true, + "open_count": 1 + }, + "action_signature": { + "source_hash": "sha256:mixed-frontier" + } + } + }), + "inspect-goal", + ) + .unwrap(); + + assert_eq!( + snapshot.run_decision, + loopx_contract::LoopxCliRunDecision::RunNow + ); + assert_eq!(snapshot.waiting_user_todo_count, 1); + } + + #[test] + fn durable_progress_on_an_unplanned_todo_does_not_settle_the_turn() { + let planned_token = "goal-1:agent-1:todo-planned:turn-1"; + let actual_effect = "goal-1:agent-1:todo-actual:turn-1"; + let identity = json!({ + "schema_version": "quota_settlement_identity_v0", + "effect_id": actual_effect, + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-actual", + "turn_instance_id": "turn-1" + }); + let payload = json!({ + "goals": [{ + "id": "goal-1", + "latest_runs": [ + { + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-actual", + "turn_instance_id": "turn-1", + "classification": "validated_progress", + "progress_observation": { + "schema_version": "typed_progress_observation_v0", + "result_class": "advanced", + "work_item_id": "todo-actual" + }, + "settlement_identity": identity.clone() + }, + { + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-actual", + "turn_instance_id": "turn-1", + "classification": "quota_slot_spent", + "settlement_identity": identity + } + ] + }] + }); + + let evidence = matching_durable_progress( + &payload, + "goal-1", + "agent-1", + "turn-1", + planned_token, + "settle-turn", + ) + .unwrap(); + assert_eq!(evidence, None); + } + + #[test] + fn legacy_validated_progress_uses_accountable_delivery_outcome() { + let effect_id = "goal-1:agent-1:todo-1:turn-1"; + let identity = json!({ + "schema_version": "quota_settlement_identity_v0", + "effect_id": effect_id, + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1" + }); + let payload = json!({ + "goals": [{ + "id": "goal-1", + "latest_runs": [ + { + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1", + "classification": "validated_progress", + "delivery_outcome": "outcome_progress", + "settlement_identity": identity.clone() + }, + { + "goal_id": "goal-1", + "agent_id": "agent-1", + "todo_id": "todo-1", + "turn_instance_id": "turn-1", + "classification": "quota_slot_spent", + "settlement_identity": identity + } + ] + }] + }); + + let evidence = matching_durable_progress( + &payload, + "goal-1", + "agent-1", + "turn-1", + effect_id, + "settle-turn", + ) + .unwrap() + .expect("legacy settlement evidence"); + assert!(evidence.quota_spent); + } +} + +fn answer_gate_args( + request: &loopx_contract::LoopxCliAnswerGateRequest, +) -> loopx_contract::LoopxCliResult> { + let operation_id = &request.context.call.operation_id; + validate_nonempty("gate_id", &request.gate_id, operation_id)?; + let decision = match request.decision { + loopx_contract::LoopxCliGateDecision::Approve => "approve", + loopx_contract::LoopxCliGateDecision::Reject => "reject", + }; + let mut args = vec![ + "todo".to_string(), + "complete".to_string(), + "--goal-id".to_string(), + request.goal_id.clone(), + "--todo-id".to_string(), + request.gate_id.clone(), + "--decision-outcome".to_string(), + decision.to_string(), + "--agent-id".to_string(), + request.agent_id.clone(), + "--execute".to_string(), + ]; + if let Some(note) = &request.note { + if note.len() > 500 { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "gate note exceeds the 500-byte adapter limit", + false, + )); + } + args.extend(["--note".to_string(), note.clone()]); + } + Ok(args.into_iter().map(OsString::from).collect()) +} + +/// Bounded plain text for UX projections; never a control fact. +fn bounded_projection_text(value: Option<&str>, limit: usize) -> String { + let raw = value.unwrap_or_default().trim(); + if raw.chars().count() <= limit { + return raw.to_string(); + } + let truncated: String = raw.chars().take(limit).collect(); + format!("{truncated}...") +} + +fn bounded_projection_field(value: &Value, key: &str) -> String { + bounded_projection_text(value.get(key).and_then(Value::as_str), 160) +} + +fn project_selected_todo(envelope: &Value) -> Option { + let action = envelope.get("action")?; + let todo = action.get("selected_todo")?; + let todo_id = bounded_projection_field(todo, "todo_id"); + if todo_id.is_empty() { + return None; + } + Some(loopx_contract::LoopxCurrentTodo { + todo_id, + task_class: bounded_projection_field(todo, "task_class"), + action_kind: bounded_projection_field(todo, "action_kind"), + target_key: bounded_projection_field(todo, "target_key"), + claimed_by: bounded_projection_field(todo, "claimed_by"), + next_due_at: todo + .get("next_due_at") + .and_then(Value::as_str) + .map(str::to_string), + recommended_action: bounded_projection_text( + action.get("recommended_action").and_then(Value::as_str), + 240, + ), + }) +} + +fn project_goal_snapshot( + goal_id: &str, + payload: &Value, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + require_payload_ok(payload, operation_id)?; + require_schema(payload, "loopx_turn_plan_v0", operation_id)?; + let envelope = payload.get("turn_envelope").ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "turn plan did not contain turn_envelope", + false, + ) + })?; + let should_run = envelope.get("should_run").and_then(Value::as_bool) == Some(true); + let user_action_required = envelope + .pointer("/user/action_required") + .and_then(Value::as_bool) + .or_else(|| envelope.get("action_required").and_then(Value::as_bool)) + == Some(true); + let state_text = envelope + .get("state") + .and_then(Value::as_str) + .unwrap_or_default(); + let effective_action = envelope + .get("effective_action") + .and_then(Value::as_str) + .unwrap_or_default(); + let control_status = payload + .get("status") + .and_then(Value::as_str) + .unwrap_or_default(); + let operator_gate_notify = matches!( + effective_action, + "operator_gate" | "operator_gate_notify" | "waiting_for_user" + ) || matches!(state_text, "operator_gate" | "operator_gate_notify") + || control_status == "operator_gate_notify"; + let run_decision = if should_run { + loopx_contract::LoopxCliRunDecision::RunNow + } else if user_action_required || operator_gate_notify { + loopx_contract::LoopxCliRunDecision::WaitingForUser + } else if effective_action == "terminal_no_followup" + || matches!( + state_text, + "completed" | "complete" | "closed" | "terminal_no_followup" + ) + { + loopx_contract::LoopxCliRunDecision::Complete + } else if matches!(state_text, "failed" | "error") { + loopx_contract::LoopxCliRunDecision::Failed + } else { + loopx_contract::LoopxCliRunDecision::Wait + }; + let state = match run_decision { + loopx_contract::LoopxCliRunDecision::RunNow => loopx_contract::LoopxCliGoalState::Active, + loopx_contract::LoopxCliRunDecision::WaitingForUser => { + loopx_contract::LoopxCliGoalState::WaitingForUser + } + loopx_contract::LoopxCliRunDecision::Complete => { + loopx_contract::LoopxCliGoalState::Completed + } + loopx_contract::LoopxCliRunDecision::Failed => loopx_contract::LoopxCliGoalState::Failed, + loopx_contract::LoopxCliRunDecision::Wait => { + if effective_action == "archived" { + loopx_contract::LoopxCliGoalState::Archived + } else { + loopx_contract::LoopxCliGoalState::Active + } + } + }; + Ok(loopx_contract::LoopxCliGoalSnapshot { + goal_id: goal_id.to_string(), + state, + durable_revision: extract_durable_revision(payload, operation_id)?, + run_decision, + scheduler_hint_ms: scheduler_hint_ms(payload), + open_todo_count: envelope + .get("open_count") + .and_then(Value::as_u64) + .unwrap_or_default() + .try_into() + .unwrap_or(u32::MAX), + waiting_user_todo_count: envelope + .pointer("/user/open_count") + .and_then(Value::as_u64) + .unwrap_or_default() + .try_into() + .unwrap_or(u32::MAX), + pending_user_gate: None, + selected_todo: project_selected_todo(envelope), + pending_replan_obligation_id: envelope + .pointer("/replan_action_packet/obligation_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string), + envelope_over_budget: envelope + .pointer("/compaction/within_budget") + .and_then(Value::as_bool) + == Some(false), + }) +} + +fn project_pending_user_gate( + payload: &Value, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + require_payload_ok(payload, operation_id)?; + let todos = payload + .get("todos") + .and_then(Value::as_array) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "todo list response did not contain todos", + false, + ) + })?; + let gate = todos + .iter() + .find(|todo| { + let status = todo + .get("status") + .and_then(Value::as_str) + .unwrap_or_default(); + todo.get("role").and_then(Value::as_str) == Some("user") + && todo.get("task_class").and_then(Value::as_str) == Some("user_gate") + && todo.get("done").and_then(Value::as_bool) != Some(true) + && !matches!( + status, + "completed" | "closed" | "done" | "archived" | "cancelled" + ) + }) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "LoopX requested a user decision without an open typed user gate", + false, + ) + })?; + Ok(loopx_contract::LoopxCliUserGate { + gate_id: required_json_string(gate, "todo_id", operation_id)?, + message: truncate_message(&required_json_string(gate, "text", operation_id)?), + action_kind: gate + .get("action_kind") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string), + }) +} + +fn extract_durable_revision( + payload: &Value, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + [ + "/action_signature/source_decision_hash", + "/action_signature/source_hash", + "/turn_envelope/action_signature/source_decision_hash", + "/turn_envelope/action_signature/source_hash", + "/transaction/turn_key", + ] + .into_iter() + .find_map(|pointer| { + payload + .pointer(pointer) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + }) + .map(str::to_string) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + "LoopX turn packet did not expose a durable revision identity", + false, + ) + }) +} + +fn stable_turn_id(request: &loopx_contract::LoopxCliBuildTurnRequest) -> String { + let mut hasher = Sha256::new(); + hasher.update(request.context.task_id.as_bytes()); + hasher.update([0]); + hasher.update(request.context.generation.to_le_bytes()); + hasher.update([0]); + hasher.update(request.goal_id.as_bytes()); + hasher.update([0]); + hasher.update(request.expected_durable_revision.as_bytes()); + let digest = hex::encode(hasher.finalize()); + format!("bitfun-{}", &digest[..32]) +} + +fn scheduler_hint_ms(payload: &Value) -> Option { + [ + "/scheduler/hint_ms", + "/scheduler/next_poll_ms", + "/turn_envelope/scheduler/hint_ms", + "/turn_envelope/scheduler/next_poll_ms", + "/scheduler_hint_ms", + ] + .into_iter() + .find_map(|pointer| payload.pointer(pointer).and_then(Value::as_u64)) +} + +fn require_payload_ok(payload: &Value, operation_id: &str) -> loopx_contract::LoopxCliResult<()> { + if payload.get("ok").and_then(Value::as_bool) == Some(false) { + let message = payload + .get("error") + .and_then(Value::as_str) + .unwrap_or("LoopX rejected the operation"); + return Err(port_error( + loopx_contract::LoopxCliErrorKind::Backend, + operation_id, + truncate_message(message), + false, + )); + } + Ok(()) +} + +fn require_schema( + payload: &Value, + expected: &str, + operation_id: &str, +) -> loopx_contract::LoopxCliResult<()> { + let actual = payload + .get("schema_version") + .and_then(Value::as_str) + .unwrap_or_default(); + if actual != expected { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + format!("expected response schema {expected}, got {actual}"), + false, + )); + } + Ok(()) +} + +fn required_json_string( + payload: &Value, + field: &str, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + payload + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + port_error( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + operation_id, + format!("LoopX response field {field} is missing or invalid"), + false, + ) + }) +} + +fn validate_goal_context( + context: &loopx_contract::LoopxCliGoalContext, +) -> loopx_contract::LoopxCliResult<()> { + let operation_id = &context.call.operation_id; + validate_operation_id(operation_id)?; + validate_nonempty("task_id", &context.task_id, operation_id)?; + validate_nonempty("worktree_path", &context.worktree_path, operation_id)?; + validate_nonempty("registry_path", &context.registry_path, operation_id)?; + if !Path::new(&context.worktree_path).is_absolute() + || !Path::new(&context.registry_path).is_absolute() + { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "LoopX local worktree and registry paths must be absolute", + false, + )); + } + Ok(()) +} + +fn validate_github_item( + item: &loopx_contract::LoopxIssueKey, + operation_id: &str, +) -> loopx_contract::LoopxCliResult<()> { + if !item.repository.host.eq_ignore_ascii_case("github.com") + || item.repository.owner.is_empty() + || item.repository.repository.is_empty() + || item.number == 0 + { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + "LoopX v0.5.1 issue-fix planning requires a canonical GitHub item", + false, + )); + } + Ok(()) +} + +fn validate_operation_id(operation_id: &str) -> loopx_contract::LoopxCliResult<()> { + validate_nonempty("operation_id", operation_id, operation_id) +} + +fn validate_nonempty( + field: &str, + value: &str, + operation_id: &str, +) -> loopx_contract::LoopxCliResult<()> { + if value.trim().is_empty() { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::InvalidInput, + operation_id, + format!("{field} is required"), + false, + )); + } + Ok(()) +} + +fn effective_deadline( + deadline_at: Option, + configured: Duration, + operation_id: &str, +) -> loopx_contract::LoopxCliResult { + let Some(deadline_at) = deadline_at else { + return Ok(configured); + }; + let remaining_ms = deadline_at.saturating_sub(now_unix_ms()); + if remaining_ms <= 0 { + return Err(port_error( + loopx_contract::LoopxCliErrorKind::Timeout, + operation_id, + "LoopX operation deadline has already expired", + true, + )); + } + Ok(configured.min(Duration::from_millis( + remaining_ms.try_into().unwrap_or(u64::MAX), + ))) +} + +fn map_port_error( + error: LoopxCliAdapterError, + operation_id: &str, +) -> loopx_contract::LoopxCliError { + let (kind, retryable) = match &error { + LoopxCliAdapterError::Unavailable => (loopx_contract::LoopxCliErrorKind::NotFound, true), + LoopxCliAdapterError::Manifest { .. } => (loopx_contract::LoopxCliErrorKind::Io, false), + LoopxCliAdapterError::VersionMismatch { .. } => { + (loopx_contract::LoopxCliErrorKind::VersionMismatch, false) + } + LoopxCliAdapterError::SchemaMismatch { .. } => { + (loopx_contract::LoopxCliErrorKind::SchemaMismatch, false) + } + LoopxCliAdapterError::Conflict { .. } => { + (loopx_contract::LoopxCliErrorKind::Conflict, true) + } + LoopxCliAdapterError::InvalidJson { .. } => { + (loopx_contract::LoopxCliErrorKind::Backend, false) + } + LoopxCliAdapterError::Process(LoopxProcessError::Timeout { .. }) => { + (loopx_contract::LoopxCliErrorKind::Timeout, true) + } + LoopxCliAdapterError::Process(LoopxProcessError::Cancelled { .. }) => { + (loopx_contract::LoopxCliErrorKind::Cancelled, true) + } + LoopxCliAdapterError::Process(LoopxProcessError::Io { .. }) => { + (loopx_contract::LoopxCliErrorKind::Io, true) + } + LoopxCliAdapterError::Process(_) => (loopx_contract::LoopxCliErrorKind::Process, true), + }; + let message = match &error { + LoopxCliAdapterError::Process(LoopxProcessError::Exited { + code, + stdout_tail, + stderr_tail, + .. + }) if !stderr_tail.is_empty() || !stdout_tail.is_empty() => { + let details = if stderr_tail.is_empty() { + stdout_tail + } else { + stderr_tail + }; + format!( + "LoopX process exited with status {code:?}: {}", + process_error_detail(details) + ) + } + _ => error.to_string(), + }; + port_error(kind, operation_id, message, retryable) +} + +fn port_error( + kind: loopx_contract::LoopxCliErrorKind, + operation_id: &str, + message: impl Into, + retryable: bool, +) -> loopx_contract::LoopxCliError { + loopx_contract::LoopxCliError::new(kind, message) + .for_operation(operation_id) + .retryable(retryable) +} + +fn report_port_progress( + progress: &dyn loopx_contract::LoopxCliProgressSink, + operation_id: &str, + task_id: Option, + stage: loopx_contract::LoopxCliProgressStage, + message: &str, +) { + progress.report(loopx_contract::LoopxCliProgress { + operation_id: operation_id.to_string(), + task_id, + stage, + message: message.to_string(), + occurred_at: now_unix_ms(), + }); +} + +fn now_unix_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + +fn truncate_message(message: &str) -> String { + message.chars().take(500).collect() +} + +fn python_version_supported(output: &str) -> bool { + let Some(version) = output.split_whitespace().find(|part| { + part.chars() + .next() + .map(|character| character.is_ascii_digit()) + .unwrap_or(false) + }) else { + return false; + }; + let mut components = version.split('.'); + let major = components + .next() + .and_then(|value| value.parse::().ok()); + let minor = components + .next() + .and_then(|value| value.parse::().ok()); + matches!((major, minor), (Some(major), Some(minor)) if major > 3 || (major == 3 && minor >= 11)) +} + +fn output_tail(bytes: &[u8]) -> Vec { + let lines = decode_loopx_output(bytes) + .lines() + .rev() + .take(20) + .map(str::to_string) + .collect::>(); + lines.into_iter().rev().collect() +} + +fn process_error_detail(lines: &[String]) -> String { + let detail = lines + .iter() + .rev() + .find(|line| line.contains("\"error\"")) + .cloned() + .unwrap_or_else(|| lines.join(" | ")); + truncate_message(&detail) +} + +fn is_public_token(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +#[derive(Debug)] +struct LoopxCandidate { + executable: PathBuf, + prefix_args: Vec, + environment: BTreeMap, + managed_source_dir: Option, + source: LoopxCommandSource, + bundle_manifest_schema: Option, + sha256: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ManagedLoopxSourceManifest { + schema_version: u32, + source_repository: String, + source_tag: String, + source_commit: String, + loopx_version: String, +} + +fn verify_managed_source_manifest( + manifest: &ManagedLoopxSourceManifest, +) -> Result<(), LoopxCliAdapterError> { + if manifest.schema_version != MANAGED_SOURCE_MANIFEST_SCHEMA + || manifest.source_repository != LOOPX_SOURCE_REPOSITORY + || manifest.source_tag != LOOPX_PINNED_VERSION_TAG + || manifest.source_commit != LOOPX_PINNED_SOURCE_COMMIT + || manifest.loopx_version != LOOPX_PINNED_VERSION + { + return Err(LoopxCliAdapterError::Manifest { + message: "managed LoopX source manifest does not match the pinned release".to_string(), + }); + } + Ok(()) +} + +#[derive(Debug, Deserialize)] +struct BundledLoopxManifest { + schema_version: u32, + name: String, + version: String, + sha256: String, +} + +fn verify_manifest(manifest: &BundledLoopxManifest) -> Result<(), LoopxCliAdapterError> { + if manifest.schema_version != LOOPX_BUNDLE_MANIFEST_SCHEMA { + return Err(LoopxCliAdapterError::SchemaMismatch { + expected: LOOPX_BUNDLE_MANIFEST_SCHEMA.to_string(), + actual: manifest.schema_version.to_string(), + }); + } + if manifest.name != "loopx" { + return Err(LoopxCliAdapterError::Manifest { + message: format!("expected bundle name loopx, got {}", manifest.name), + }); + } + if manifest.version != LOOPX_PINNED_VERSION_TAG { + return Err(LoopxCliAdapterError::VersionMismatch { + expected: LOOPX_PINNED_VERSION_TAG.to_string(), + actual: manifest.version.clone(), + }); + } + let digest = manifest.sha256.strip_prefix("sha256:").unwrap_or_default(); + if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(LoopxCliAdapterError::Manifest { + message: "manifest sha256 must contain a 64-digit sha256 digest".to_string(), + }); + } + Ok(()) +} + +async fn sha256_file(path: &Path) -> Result { + let mut file = + tokio::fs::File::open(path) + .await + .map_err(|error| LoopxCliAdapterError::Manifest { + message: error.to_string(), + })?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = + file.read(&mut buffer) + .await + .map_err(|error| LoopxCliAdapterError::Manifest { + message: error.to_string(), + })?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex::encode(hasher.finalize())) +} + +struct OperationRegistration { + operation_id: String, + running: Arc>>, +} + +impl Drop for OperationRegistration { + fn drop(&mut self) { + self.running + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .remove(&self.operation_id); + } +} + +struct StdoutCapture { + bytes: Vec, + exceeded_limit: bool, +} + +async fn capture_stdout(mut reader: impl AsyncRead + Unpin) -> StdoutCapture { + let mut bytes = Vec::new(); + let mut exceeded_limit = false; + let mut buffer = [0_u8; 16 * 1024]; + loop { + let read = match reader.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(read) => read, + }; + let remaining = MAX_STDOUT_BYTES.saturating_sub(bytes.len()); + if read > remaining { + bytes.extend_from_slice(&buffer[..remaining]); + exceeded_limit = true; + } else { + bytes.extend_from_slice(&buffer[..read]); + } + } + StdoutCapture { + bytes, + exceeded_limit, + } +} + +async fn capture_stderr( + mut reader: impl AsyncRead + Unpin, + line_sender: mpsc::Sender, +) -> Vec { + let mut pending = Vec::new(); + let mut tail = VecDeque::new(); + let mut tail_bytes = 0_usize; + let mut buffer = [0_u8; 8 * 1024]; + loop { + let read = match reader.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(read) => read, + }; + pending.extend_from_slice(&buffer[..read]); + while let Some(newline) = pending.iter().position(|byte| *byte == b'\n') { + let line = pending.drain(..=newline).collect::>(); + record_stderr_line(&line, &line_sender, &mut tail, &mut tail_bytes); + } + if pending.len() > MAX_PROGRESS_LINE_BYTES * 2 { + let line = pending.drain(..MAX_PROGRESS_LINE_BYTES).collect::>(); + record_stderr_line(&line, &line_sender, &mut tail, &mut tail_bytes); + } + } + if !pending.is_empty() { + record_stderr_line(&pending, &line_sender, &mut tail, &mut tail_bytes); + } + tail.into_iter().collect() +} + +fn record_stderr_line( + raw: &[u8], + line_sender: &mpsc::Sender, + tail: &mut VecDeque, + tail_bytes: &mut usize, +) { + let raw = raw + .strip_suffix(b"\n") + .unwrap_or(raw) + .strip_suffix(b"\r") + .unwrap_or(raw); + if raw.is_empty() { + return; + } + let line = decode_loopx_output(&raw[..raw.len().min(MAX_PROGRESS_LINE_BYTES)]); + let _ = line_sender.try_send(line.clone()); + *tail_bytes += line.len(); + tail.push_back(line); + while *tail_bytes > MAX_STDERR_TAIL_BYTES { + if let Some(removed) = tail.pop_front() { + *tail_bytes = tail_bytes.saturating_sub(removed.len()); + } else { + break; + } + } +} + +async fn drain_stdout_task( + task: &mut tokio::task::JoinHandle, +) -> Result { + match tokio::time::timeout(PIPE_DRAIN_DEADLINE, &mut *task).await { + Ok(Ok(capture)) => Ok(capture), + Ok(Err(error)) => Err(LoopxProcessError::Io { + message: error.to_string(), + }), + Err(_) => { + task.abort(); + Err(LoopxProcessError::Io { + message: "LoopX stdout pipe stayed open after process exit".to_string(), + }) + } + } +} + +async fn drain_stderr_task(task: &mut tokio::task::JoinHandle>) -> Vec { + match tokio::time::timeout(PIPE_DRAIN_DEADLINE, &mut *task).await { + Ok(Ok(tail)) => tail, + Ok(Err(_)) => Vec::new(), + Err(_) => { + task.abort(); + Vec::new() + } + } +} + +fn emit_progress( + observer: &dyn LoopxProcessObserver, + operation_id: &str, + stage: LoopxProgressStage, + message: &str, +) { + observer.on_progress(LoopxProcessProgress { + operation_id: operation_id.to_string(), + stage, + message: message.to_string(), + occurred_at_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX), + }); +} + +fn duration_millis(duration: Duration) -> u64 { + duration.as_millis().try_into().unwrap_or(u64::MAX) +} diff --git a/src/crates/services/services-integrations/src/miniapp/loopx_github.rs b/src/crates/services/services-integrations/src/miniapp/loopx_github.rs new file mode 100644 index 0000000000..7263c22cdf --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp/loopx_github.rs @@ -0,0 +1,1192 @@ +use super::loopx_cli::LoopxIntakeMetadataProvider; +use async_trait::async_trait; +use openbitfun_product_domains::miniapp::loopx as loopx_contract; +use reqwest::header::{HeaderMap, ETAG, IF_NONE_MATCH, RETRY_AFTER}; +use reqwest::{Client, StatusCode}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeSet, HashMap}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::sync::Mutex; + +const GITHUB_API_ROOT: &str = "https://api.github.com"; +const INTAKE_PAGE_SIZE: usize = 100; +const INTAKE_MAX_REPOSITORY_PAGES: usize = 10; +const AUTHENTICATED_CREDENTIAL_TTL: Duration = Duration::from_secs(300); +const MISSING_CREDENTIAL_TTL: Duration = Duration::from_secs(30); +const RESPONSE_CACHE_TTL: Duration = Duration::from_secs(60); +const RATE_SNAPSHOT_TTL: Duration = Duration::from_secs(30); +const MAX_RESPONSE_CACHE_ENTRIES: usize = 256; +const DEFAULT_SECONDARY_BACKOFF: Duration = Duration::from_secs(60); +/// Upper bound of the plain-text excerpt kept per issue/PR body. Keeps task +/// snapshots (and the event stream) bounded while still giving task surfaces +/// readable context. +const DESCRIPTION_EXCERPT_MAX_CHARS: usize = 600; + +/// Trims a GitHub markdown body into a bounded plain-text excerpt used by +/// task surfaces. Markdown image/link syntax is resolved to its visible text, +/// common markup characters are folded away, and the result is whitespace- +/// normalized. The full body is never projected into the candidate/task +/// snapshot. +fn candidate_description(body: &str) -> String { + let mut excerpt = String::with_capacity(body.len().min(DESCRIPTION_EXCERPT_MAX_CHARS * 3)); + let mut chars = body.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '!' if chars.peek() == Some(&'[') => { + // `![alt](url)` → keep alt, drop url. + chars.next(); // '[' + let alt = take_until(&mut chars, ']'); + if chars.peek() == Some(&'(') { + take_until(&mut chars, ')'); + } + push_word(&mut excerpt, &alt); + } + '[' => { + // `[text](url)` → keep text, drop url. + let text = take_until(&mut chars, ']'); + if chars.peek() == Some(&'(') { + take_until(&mut chars, ')'); + } + push_word(&mut excerpt, &text); + } + '#' | '*' | '_' | '`' | '>' | '~' => push_word(&mut excerpt, ""), + _ => excerpt.push(ch), + } + } + let normalized = excerpt.split_whitespace().collect::>().join(" "); + let mut normalized_chars = normalized.chars(); + let mut bounded = normalized_chars + .by_ref() + .take(DESCRIPTION_EXCERPT_MAX_CHARS) + .collect::(); + if normalized_chars.next().is_some() { + bounded.push('…'); + } + bounded +} + +/// Reads characters up to and including `stop`, returning the text before it. +fn take_until(chars: &mut std::iter::Peekable>, stop: char) -> String { + let mut text = String::new(); + while let Some(next) = chars.next() { + if next == stop { + break; + } + text.push(next); + } + text +} + +/// Appends `word` to `excerpt`, separated by a space when non-empty. +fn push_word(excerpt: &mut String, word: &str) { + let word = word.trim(); + if word.is_empty() { + return; + } + if !excerpt.is_empty() && !excerpt.ends_with(' ') { + excerpt.push(' '); + } + excerpt.push_str(word); +} + +#[derive(Clone)] +struct GithubCredential { + token: Option, + source: Option<&'static str>, + detail: String, +} + +impl GithubCredential { + fn cache_scope(&self) -> String { + let Some(token) = self.token.as_deref() else { + return "anonymous".to_string(); + }; + let digest = Sha256::digest(token.as_bytes()); + let fingerprint = digest[..12] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("authenticated:{fingerprint}") + } +} + +#[derive(Clone)] +struct CachedCredential { + credential: GithubCredential, + checked_at: Instant, +} + +#[derive(Clone)] +struct CachedGithubResponse { + value: Value, + etag: Option, + stored_at: Instant, +} + +#[derive(Debug, Clone)] +struct RateLimitSnapshot { + credential_scope: String, + limit: Option, + remaining: Option, + reset_at: Option, + observed_at: Instant, +} + +#[derive(Debug, Clone)] +struct RateLimitBlock { + credential_scope: String, + until: Instant, + message: String, +} + +#[derive(Debug, Default)] +struct GithubRateState { + snapshot: Option, + block: Option, +} + +#[derive(Clone)] +pub struct GithubLoopxIntakeMetadataProvider { + client: Client, + credential: Arc>>, + responses: Arc>>, + rate: Arc>, + request_gate: Arc>, +} + +impl GithubLoopxIntakeMetadataProvider { + pub fn new() -> Result { + let client = Client::builder() + .user_agent("BitFun LoopX MiniApp") + .build() + .map_err(|error| format!("Failed to build GitHub client: {error}"))?; + Ok(Self { + client, + credential: Arc::new(Mutex::new(None)), + responses: Arc::new(Mutex::new(HashMap::new())), + rate: Arc::new(Mutex::new(GithubRateState::default())), + request_gate: Arc::new(Mutex::new(())), + }) + } + + async fn github_credential(&self, force_refresh: bool) -> GithubCredential { + let mut cached = self.credential.lock().await; + if !force_refresh { + if let Some(existing) = cached.as_ref() { + let ttl = if existing.credential.token.is_some() { + AUTHENTICATED_CREDENTIAL_TTL + } else { + MISSING_CREDENTIAL_TTL + }; + if existing.checked_at.elapsed() < ttl { + return existing.credential.clone(); + } + } + } + let credential = load_github_credential().await; + *cached = Some(CachedCredential { + credential: credential.clone(), + checked_at: Instant::now(), + }); + credential + } + + async fn invalidate_credential(&self) { + *self.credential.lock().await = None; + } + + async fn active_rate_limit(&self, credential_scope: &str) -> Option { + let mut rate = self.rate.lock().await; + match rate.block.as_ref() { + Some(block) if block.credential_scope != credential_scope => { + rate.block = None; + None + } + Some(block) if block.until > Instant::now() => Some(block.clone()), + Some(_) => { + rate.block = None; + None + } + None => None, + } + } + + async fn recent_rate_snapshot(&self, credential_scope: &str) -> Option { + self.rate.lock().await.snapshot.clone().filter(|snapshot| { + snapshot.credential_scope == credential_scope + && snapshot.observed_at.elapsed() < RATE_SNAPSHOT_TTL + }) + } + + async fn store_rate_snapshot( + &self, + credential_scope: &str, + limit: Option, + remaining: Option, + reset_at: Option, + ) { + self.rate.lock().await.snapshot = Some(RateLimitSnapshot { + credential_scope: credential_scope.to_string(), + limit, + remaining, + reset_at, + observed_at: Instant::now(), + }); + } + + async fn record_rate_headers( + &self, + status: StatusCode, + headers: &HeaderMap, + credential_scope: &str, + ) -> Option { + let now = Instant::now(); + let unix_now = now_unix_seconds(); + let limit = header_u64(headers, "x-ratelimit-limit"); + let remaining = header_u64(headers, "x-ratelimit-remaining"); + let reset_at = header_u64(headers, "x-ratelimit-reset"); + let retry_after = header_u64(headers, RETRY_AFTER.as_str()); + let mut rate = self.rate.lock().await; + if limit.is_some() || remaining.is_some() || reset_at.is_some() { + rate.snapshot = Some(RateLimitSnapshot { + credential_scope: credential_scope.to_string(), + limit, + remaining, + reset_at, + observed_at: now, + }); + } + let block = if remaining == Some(0) { + let wait_seconds = reset_at + .map(|reset| reset.saturating_sub(unix_now).max(1)) + .unwrap_or(60); + Some(RateLimitBlock { + credential_scope: credential_scope.to_string(), + until: now + Duration::from_secs(wait_seconds), + message: format!( + "GitHub primary API rate limit is exhausted; retry in {wait_seconds} seconds{}", + reset_at + .map(|reset| format!(" (reset epoch {reset})")) + .unwrap_or_default() + ), + }) + } else if status == StatusCode::TOO_MANY_REQUESTS || retry_after.is_some() { + let wait_seconds = retry_after + .unwrap_or(DEFAULT_SECONDARY_BACKOFF.as_secs()) + .max(1); + Some(RateLimitBlock { + credential_scope: credential_scope.to_string(), + until: now + Duration::from_secs(wait_seconds), + message: format!( + "GitHub secondary API rate limit is active; retry in {wait_seconds} seconds" + ), + }) + } else { + None + }; + if let Some(block) = block.clone() { + rate.block = Some(block); + } else if status.is_success() { + rate.block = None; + } + block + } + + async fn record_secondary_limit(&self, credential_scope: &str) -> RateLimitBlock { + let block = RateLimitBlock { + credential_scope: credential_scope.to_string(), + until: Instant::now() + DEFAULT_SECONDARY_BACKOFF, + message: format!( + "GitHub secondary API rate limit is active; retry in {} seconds", + DEFAULT_SECONDARY_BACKOFF.as_secs() + ), + }; + self.rate.lock().await.block = Some(block.clone()); + block + } + + async fn cached_response( + &self, + credential_scope: &str, + path: &str, + ) -> Option { + self.responses + .lock() + .await + .get(&response_cache_key(credential_scope, path)) + .cloned() + } + + async fn store_response( + &self, + credential_scope: &str, + path: &str, + value: Value, + etag: Option, + ) { + let cache_key = response_cache_key(credential_scope, path); + let mut responses = self.responses.lock().await; + if responses.len() >= MAX_RESPONSE_CACHE_ENTRIES && !responses.contains_key(&cache_key) { + if let Some(oldest) = responses + .iter() + .min_by_key(|(_, response)| response.stored_at) + .map(|(path, _)| path.clone()) + { + responses.remove(&oldest); + } + } + responses.insert( + cache_key, + CachedGithubResponse { + value, + etag, + stored_at: Instant::now(), + }, + ); + } + + async fn get_json( + &self, + path: &str, + deadline: Duration, + operation_id: &str, + ) -> loopx_contract::LoopxCliResult { + let _request = self.request_gate.lock().await; + let credential = self.github_credential(false).await; + let credential_scope = credential.cache_scope(); + let cached = self.cached_response(&credential_scope, path).await; + if let Some(cached) = cached.as_ref() { + if cached.stored_at.elapsed() < RESPONSE_CACHE_TTL { + return Ok(cached.value.clone()); + } + } + if let Some(block) = self.active_rate_limit(&credential_scope).await { + return Err(rate_limit_error(operation_id, block)); + } + let mut request = self + .client + .get(format!("{GITHUB_API_ROOT}{path}")) + .timeout(deadline); + if let Some(token) = credential.token.as_deref() { + request = request.bearer_auth(token); + } + if credential.token.is_some() { + if let Some(etag) = cached.as_ref().and_then(|cached| cached.etag.as_deref()) { + request = request.header(IF_NONE_MATCH, etag); + } + } + let response = request.send().await.map_err(|error| { + loopx_contract::LoopxCliError::new( + if error.is_timeout() { + loopx_contract::LoopxCliErrorKind::Timeout + } else { + loopx_contract::LoopxCliErrorKind::Backend + }, + format!("GitHub intake request failed: {error}"), + ) + .for_operation(operation_id) + .retryable(true) + })?; + let status = response.status(); + let headers = response.headers().clone(); + if let Some(block) = self + .record_rate_headers(status, &headers, &credential_scope) + .await + { + return Err(rate_limit_error(operation_id, block)); + } + if status == StatusCode::NOT_MODIFIED { + return cached.map(|cached| cached.value).ok_or_else(|| { + loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + "GitHub returned 304 without a cached response", + ) + .for_operation(operation_id) + }); + } + if status == StatusCode::UNAUTHORIZED { + self.invalidate_credential().await; + return Err(loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::Backend, + "GitHub authentication is invalid or expired; run `gh auth login --hostname github.com --web`", + ) + .for_operation(operation_id)); + } + if status == StatusCode::NOT_FOUND { + return Err(loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::NotFound, + "GitHub repository, issue, or pull request was not found", + ) + .for_operation(operation_id)); + } + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let api_detail = github_error_detail(&body); + let lower_detail = api_detail.to_ascii_lowercase(); + if status == StatusCode::FORBIDDEN + && (lower_detail.contains("secondary rate limit") + || lower_detail.contains("abuse detection")) + { + let block = self.record_secondary_limit(&credential_scope).await; + return Err(rate_limit_error(operation_id, block)); + } + let message = if status == StatusCode::FORBIDDEN { + if credential.token.is_none() { + format!( + "GitHub request was forbidden without authentication; {}", + credential.detail + ) + } else { + format!( + "GitHub request was forbidden (403); the repository may be private or the credential lacks access{}", + optional_api_detail(&api_detail) + ) + } + } else { + format!( + "GitHub intake request returned HTTP {status}{}", + optional_api_detail(&api_detail) + ) + }; + return Err(loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::Backend, + message, + ) + .for_operation(operation_id) + .retryable(status.is_server_error())); + } + let etag = headers + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let value = response.json::().await.map_err(|error| { + loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + format!("GitHub intake response was invalid: {error}"), + ) + .for_operation(operation_id) + })?; + self.store_response(&credential_scope, path, value.clone(), etag) + .await; + Ok(value) + } + + async fn resolve_repository_candidates( + &self, + repository: &loopx_contract::LoopxRepositoryKey, + deadline: Duration, + operation_id: &str, + ) -> loopx_contract::LoopxCliResult<(Vec, bool)> { + let mut candidates = Vec::new(); + let mut seen = BTreeSet::new(); + for page in 1..=INTAKE_MAX_REPOSITORY_PAGES { + let value = self + .get_json( + &format!( + "/repos/{}/{}/issues?state=open&sort=updated&direction=desc&per_page={INTAKE_PAGE_SIZE}&page={page}", + repository.owner, repository.repository + ), + deadline, + operation_id, + ) + .await?; + let rows = value.as_array().ok_or_else(|| { + loopx_contract::LoopxCliError::new( + loopx_contract::LoopxCliErrorKind::SchemaMismatch, + "GitHub issues response was not an array", + ) + .for_operation(operation_id) + })?; + append_repository_issue_candidates(repository, rows, &mut seen, &mut candidates); + if rows.len() < INTAKE_PAGE_SIZE { + return Ok((candidates, false)); + } + } + Ok((candidates, true)) + } +} + +async fn load_github_credential() -> GithubCredential { + if let Some((name, token)) = ["GH_TOKEN", "GITHUB_TOKEN"].into_iter().find_map(|name| { + std::env::var(name) + .ok() + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()) + .map(|token| (name, token)) + }) { + return GithubCredential { + token: Some(token), + source: Some("environment"), + detail: format!("GitHub authentication is configured through {name}"), + }; + } + let Some(executable) = resolve_github_cli() else { + return GithubCredential { + token: None, + source: None, + detail: "GitHub CLI was not found and GH_TOKEN/GITHUB_TOKEN is not set".to_string(), + }; + }; + let output = match tokio::process::Command::new(executable) + .args(["auth", "token", "--hostname", "github.com"]) + .output() + .await + { + Ok(output) => output, + Err(error) => { + return GithubCredential { + token: None, + source: None, + detail: format!("GitHub CLI credential lookup failed: {error}"), + }; + } + }; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = stderr + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .map(str::trim) + .unwrap_or("run `gh auth login --hostname github.com --web`"); + return GithubCredential { + token: None, + source: None, + detail: format!("GitHub CLI is not authenticated: {detail}"), + }; + } + let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if token.is_empty() { + GithubCredential { + token: None, + source: None, + detail: "GitHub CLI returned an empty credential".to_string(), + } + } else { + GithubCredential { + token: Some(token), + source: Some("GitHub CLI"), + detail: "GitHub authentication is provided by the local GitHub CLI login".to_string(), + } + } +} + +fn header_u64(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()) +} + +fn response_cache_key(credential_scope: &str, path: &str) -> String { + format!("{credential_scope}\n{path}") +} + +fn rate_limit_error(operation_id: &str, block: RateLimitBlock) -> loopx_contract::LoopxCliError { + loopx_contract::LoopxCliError::new(loopx_contract::LoopxCliErrorKind::Backend, block.message) + .for_operation(operation_id) + .retryable(false) +} + +fn github_error_detail(body: &str) -> String { + let detail = serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .get("message") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| body.trim().to_string()); + detail.chars().take(300).collect() +} + +fn optional_api_detail(detail: &str) -> String { + if detail.is_empty() { + String::new() + } else { + format!(": {detail}") + } +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn resolve_github_cli() -> Option { + if let Ok(path) = which::which("gh") { + return Some(path); + } + github_cli_candidates() + .into_iter() + .find(|path| path.is_file()) +} + +fn github_cli_candidates() -> Vec { + #[cfg(windows)] + { + let mut candidates = Vec::new(); + for variable in ["ProgramFiles", "ProgramFiles(x86)"] { + if let Some(root) = std::env::var_os(variable) { + candidates.push(PathBuf::from(root).join("GitHub CLI").join("gh.exe")); + } + } + if let Some(root) = std::env::var_os("LOCALAPPDATA") { + candidates.push( + PathBuf::from(root) + .join("Programs") + .join("GitHub CLI") + .join("gh.exe"), + ); + } + candidates + } + #[cfg(not(windows))] + { + Vec::new() + } +} + +#[async_trait] +impl LoopxIntakeMetadataProvider for GithubLoopxIntakeMetadataProvider { + async fn resolve( + &self, + request: &loopx_contract::LoopxCliResolveIntakeRequest, + deadline: Duration, + ) -> loopx_contract::LoopxCliResult { + let repository = request.target.repository().clone(); + let operation_id = &request.call.operation_id; + let mut candidates = Vec::new(); + let mut truncated = false; + match &request.target { + loopx_contract::LoopxIntakeTarget::Item { item } => { + let collection = match item.kind { + loopx_contract::LoopxItemKind::Issue => "issues", + loopx_contract::LoopxItemKind::PullRequest => "pulls", + }; + let value = self + .get_json( + &format!( + "/repos/{}/{}/{collection}/{}", + repository.owner, repository.repository, item.number + ), + deadline, + operation_id, + ) + .await?; + candidates.push(candidate_from_value(item.clone(), &value, false)); + } + loopx_contract::LoopxIntakeTarget::Repository { .. } => { + let (resolved_candidates, was_truncated) = self + .resolve_repository_candidates(&repository, deadline, operation_id) + .await?; + candidates = resolved_candidates; + truncated = was_truncated; + } + } + Ok(loopx_contract::LoopxCliResolveIntakeResult { + target: request.target.clone(), + repository, + candidates, + truncated, + resolved_at: now_ms(), + }) + } + + async fn viewer_merge_authority( + &self, + repository: &loopx_contract::LoopxRepositoryKey, + deadline: Duration, + ) -> loopx_contract::LoopxCliResult> { + let path = format!("/repos/{}/{}", repository.owner, repository.repository); + let value = match self + .get_json(&path, deadline, "github-merge-authority") + .await + { + Ok(value) => value, + Err(error) if error.kind == loopx_contract::LoopxCliErrorKind::NotFound => { + // Unknown repository for this identity: treat as no authority + // rather than blocking the gate behind a probe failure. + return Ok(Some(false)); + } + Err(_) => return Ok(None), + }; + let Some(permissions) = value.get("permissions") else { + // Unauthenticated responses omit the viewer permission block. + return Ok(None); + }; + let can = |field: &str| { + permissions + .get(field) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }; + Ok(Some(can("push") || can("maintain") || can("admin"))) + } + + async fn probe_auth( + &self, + deadline: Duration, + ) -> loopx_contract::LoopxCliResult { + const OPERATION_ID: &str = "github-auth-probe"; + let _request = self.request_gate.lock().await; + let credential = self.github_credential(false).await; + let credential_scope = credential.cache_scope(); + if let Some(block) = self.active_rate_limit(&credential_scope).await { + let snapshot = self.recent_rate_snapshot(&credential_scope).await; + return Ok(build_github_auth_probe( + &credential, + snapshot.as_ref(), + Some(block.message), + )); + } + if let Some(snapshot) = self.recent_rate_snapshot(&credential_scope).await { + return Ok(build_github_auth_probe(&credential, Some(&snapshot), None)); + } + let mut request = self + .client + .get(format!("{GITHUB_API_ROOT}/rate_limit")) + .timeout(deadline); + if let Some(token) = credential.token.as_deref() { + request = request.bearer_auth(token); + } + let response = request.send().await.map_err(|error| { + loopx_contract::LoopxCliError::new( + if error.is_timeout() { + loopx_contract::LoopxCliErrorKind::Timeout + } else { + loopx_contract::LoopxCliErrorKind::Backend + }, + format!("GitHub auth probe failed: {error}"), + ) + .for_operation(OPERATION_ID) + .retryable(true) + })?; + let status = response.status(); + let headers = response.headers().clone(); + let header_block = self + .record_rate_headers(status, &headers, &credential_scope) + .await; + if status == StatusCode::UNAUTHORIZED { + self.invalidate_credential().await; + return Ok(loopx_contract::LoopxGithubAuthProbe { + authenticated: false, + detail: Some( + "GitHub authentication is invalid or expired; run `gh auth login --hostname github.com --web`" + .to_string(), + ), + ..loopx_contract::LoopxGithubAuthProbe::default() + }); + } + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let api_detail = github_error_detail(&body); + let lower_detail = api_detail.to_ascii_lowercase(); + let block = if let Some(block) = header_block { + Some(block) + } else if status == StatusCode::FORBIDDEN + && (lower_detail.contains("secondary rate limit") + || lower_detail.contains("abuse detection")) + { + Some(self.record_secondary_limit(&credential_scope).await) + } else { + None + }; + return Ok(loopx_contract::LoopxGithubAuthProbe { + authenticated: false, + detail: Some(block.map(|block| block.message).unwrap_or_else(|| { + format!( + "GitHub auth probe returned HTTP {status}{}; {}", + optional_api_detail(&api_detail), + credential.detail + ) + })), + ..loopx_contract::LoopxGithubAuthProbe::default() + }); + } + + let parsed = response.json::().await.ok(); + let limit = parsed + .as_ref() + .and_then(|value| value.pointer("/rate/limit")) + .and_then(Value::as_u64); + let remaining = parsed + .as_ref() + .and_then(|value| value.pointer("/rate/remaining")) + .and_then(Value::as_u64); + let reset_at = parsed + .as_ref() + .and_then(|value| value.pointer("/rate/reset")) + .and_then(Value::as_u64); + self.store_rate_snapshot(&credential_scope, limit, remaining, reset_at) + .await; + let snapshot = RateLimitSnapshot { + credential_scope, + limit, + remaining, + reset_at, + observed_at: Instant::now(), + }; + Ok(build_github_auth_probe( + &credential, + Some(&snapshot), + header_block.map(|block| block.message), + )) + } +} + +fn candidate_labels(value: &Value) -> Vec { + // LoopX's own metadata projection caps labels at 12 entries; mirror that + // bound so the inline metadata stays within the workflow-plan budget. + const LABEL_CAP: usize = 12; + value + .get("labels") + .and_then(Value::as_array) + .map(|entries| { + entries + .iter() + .filter_map(|entry| { + let name = match entry { + Value::String(text) => Some(text.clone()), + Value::Object(_) => entry + .get("name") + .and_then(Value::as_str) + .map(str::to_string), + _ => None, + }?; + let name = name.trim().to_string(); + (!name.is_empty()).then_some(name) + }) + .take(LABEL_CAP) + .collect() + }) + .unwrap_or_default() +} + +fn candidate_from_value( + key: loopx_contract::LoopxIssueKey, + value: &Value, + from_repository: bool, +) -> loopx_contract::LoopxIntakeCandidate { + let merged = value.get("merged_at").is_some_and(|value| !value.is_null()); + let state = if merged { + loopx_contract::LoopxRemoteItemState::Merged + } else { + match value.get("state").and_then(Value::as_str) { + Some("open") => loopx_contract::LoopxRemoteItemState::Open, + Some("closed") => loopx_contract::LoopxRemoteItemState::Closed, + _ => loopx_contract::LoopxRemoteItemState::Unknown, + } + }; + let body = value + .get("body") + .and_then(Value::as_str) + .unwrap_or_default(); + loopx_contract::LoopxIntakeCandidate { + url: key.canonical_url(), + title: value + .get("title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + description: candidate_description(body), + state, + state_reason: value + .get("state_reason") + .and_then(Value::as_str) + .map(str::to_string), + labels: candidate_labels(value), + from_repository, + has_images: body.contains("![") || body.to_ascii_lowercase().contains(" i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +fn build_github_auth_probe( + credential: &GithubCredential, + snapshot: Option<&RateLimitSnapshot>, + override_detail: Option, +) -> loopx_contract::LoopxGithubAuthProbe { + let authenticated = credential.token.is_some(); + let remaining = snapshot.and_then(|snapshot| snapshot.remaining); + let detail = override_detail.unwrap_or_else(|| match (authenticated, remaining) { + (true, Some(count)) => format!( + "Authenticated GitHub access via {} ({count} requests remaining this hour)", + credential.source.unwrap_or("configured credential") + ), + (true, None) => format!( + "Authenticated GitHub access via {}", + credential.source.unwrap_or("configured credential") + ), + (false, Some(count)) => format!( + "Unauthenticated GitHub access ({count} of {} requests remaining this hour); {}", + snapshot.and_then(|snapshot| snapshot.limit).unwrap_or(60), + credential.detail + ), + (false, None) => credential.detail.clone(), + }); + let detail = if remaining == Some(0) { + let reset = snapshot + .and_then(|snapshot| snapshot.reset_at) + .map(|reset| format!("; reset epoch {reset}")) + .unwrap_or_default(); + format!("{detail}{reset}") + } else { + detail + }; + loopx_contract::LoopxGithubAuthProbe { + authenticated, + rate_limit_remaining: remaining, + detail: Some(detail), + } +} + +fn append_repository_issue_candidates( + repository: &loopx_contract::LoopxRepositoryKey, + rows: &[Value], + seen: &mut BTreeSet, + candidates: &mut Vec, +) { + for value in rows { + if value.get("pull_request").is_some() { + continue; + } + let Some(number) = value.get("number").and_then(Value::as_u64) else { + continue; + }; + if !seen.insert(number) { + continue; + } + candidates.push(candidate_from_value( + loopx_contract::LoopxIssueKey { + repository: repository.clone(), + kind: loopx_contract::LoopxItemKind::Issue, + number, + }, + value, + true, + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::header::HeaderValue; + + #[test] + fn metadata_projection_keeps_bounded_plain_text_excerpt_not_full_body() { + let key = loopx_contract::LoopxIssueKey { + repository: loopx_contract::LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: loopx_contract::LoopxItemKind::Issue, + number: 7, + }; + let value = serde_json::json!({ + "number": 7, + "title": "Visible title", + "state": "open", + "body": "private-looking body ![image](https://example.test/image.png) and a [link](https://example.test/x)" + }); + let candidate = candidate_from_value(key, &value, false); + assert_eq!(candidate.title, "Visible title"); + assert!(candidate.has_images); + assert!(candidate.description.contains("private-looking")); + assert!(candidate.description.contains("image")); + // Full body and remote URLs never leak into the projection. + let serialized = serde_json::to_string(&candidate).expect("serialize"); + assert!(!serialized.contains("https://example.test")); + assert!(candidate.description.chars().count() <= DESCRIPTION_EXCERPT_MAX_CHARS + 1); + } + + #[test] + fn metadata_projection_counts_unicode_characters_and_marks_real_truncation() { + let cjk_body = format!("{} sh 命令。", "中".repeat(210)); + assert!(cjk_body.len() > DESCRIPTION_EXCERPT_MAX_CHARS); + assert_eq!(candidate_description(&cjk_body), cjk_body); + + let oversized = "项".repeat(DESCRIPTION_EXCERPT_MAX_CHARS + 1); + let projected = candidate_description(&oversized); + assert_eq!(projected.chars().count(), DESCRIPTION_EXCERPT_MAX_CHARS + 1); + assert!(projected.ends_with('…')); + } + + #[test] + fn repository_candidate_collection_filters_pull_requests_and_duplicates() { + let repository = loopx_contract::LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }; + let rows = vec![ + serde_json::json!({ + "number": 7, + "title": "First issue", + "state": "open", + "body": "" + }), + serde_json::json!({ + "number": 8, + "title": "Pull request", + "state": "open", + "pull_request": {} + }), + serde_json::json!({ + "number": 7, + "title": "Duplicate issue", + "state": "open", + "body": "" + }), + ]; + let mut seen = BTreeSet::new(); + let mut candidates = Vec::new(); + + append_repository_issue_candidates(&repository, &rows, &mut seen, &mut candidates); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].key.number, 7); + assert!(candidates[0].from_repository); + assert!(!candidates[0].default_selected); + } + + #[tokio::test] + async fn primary_rate_limit_blocks_local_retries_until_reset() { + let provider = GithubLoopxIntakeMetadataProvider::new().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("x-ratelimit-limit", HeaderValue::from_static("5000")); + headers.insert("x-ratelimit-remaining", HeaderValue::from_static("0")); + headers.insert( + "x-ratelimit-reset", + HeaderValue::from_str(&(now_unix_seconds() + 120).to_string()).unwrap(), + ); + + let block = provider + .record_rate_headers(StatusCode::FORBIDDEN, &headers, "authenticated:account-a") + .await + .expect("primary limit should create a local block"); + assert!(block.message.contains("primary API rate limit")); + assert!(provider + .active_rate_limit("authenticated:account-a") + .await + .is_some()); + assert!(provider.active_rate_limit("anonymous").await.is_none()); + let error = rate_limit_error("rate-limited", block); + assert!(!error.retryable); + assert_eq!(error.operation_id.as_deref(), Some("rate-limited")); + + let snapshot = provider + .recent_rate_snapshot("authenticated:account-a") + .await + .unwrap(); + assert_eq!(snapshot.limit, Some(5000)); + assert_eq!(snapshot.remaining, Some(0)); + } + + #[tokio::test] + async fn retry_after_creates_a_secondary_rate_limit_block() { + let provider = GithubLoopxIntakeMetadataProvider::new().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert(RETRY_AFTER, HeaderValue::from_static("90")); + + let block = provider + .record_rate_headers(StatusCode::FORBIDDEN, &headers, "authenticated:account-a") + .await + .expect("retry-after should create a local block"); + assert!(block.message.contains("secondary API rate limit")); + assert!(block.message.contains("90 seconds")); + } + + #[tokio::test] + async fn response_cache_keeps_value_and_etag_for_conditional_requests() { + let provider = GithubLoopxIntakeMetadataProvider::new().unwrap(); + let value = serde_json::json!({"number": 42, "state": "open"}); + provider + .store_response( + "authenticated:account-a", + "/repos/owner/repo/issues/42", + value.clone(), + Some("\"etag-42\"".to_string()), + ) + .await; + + let cached = provider + .cached_response("authenticated:account-a", "/repos/owner/repo/issues/42") + .await + .unwrap(); + assert_eq!(cached.value, value); + assert_eq!(cached.etag.as_deref(), Some("\"etag-42\"")); + assert!(cached.stored_at.elapsed() < RESPONSE_CACHE_TTL); + assert!(provider + .cached_response("authenticated:account-b", "/repos/owner/repo/issues/42") + .await + .is_none()); + } + + #[test] + fn auth_probe_reports_credential_source_and_anonymous_diagnostics() { + let snapshot = RateLimitSnapshot { + credential_scope: "authenticated:account-a".to_string(), + limit: Some(5000), + remaining: Some(4999), + reset_at: None, + observed_at: Instant::now(), + }; + let authenticated = build_github_auth_probe( + &GithubCredential { + token: Some("secret-not-rendered".to_string()), + source: Some("GitHub CLI"), + detail: String::new(), + }, + Some(&snapshot), + None, + ); + assert!(authenticated.authenticated); + assert!(authenticated + .detail + .as_deref() + .is_some_and(|detail| detail.contains("via GitHub CLI"))); + assert!(!authenticated + .detail + .as_deref() + .unwrap_or_default() + .contains("secret-not-rendered")); + + let anonymous = build_github_auth_probe( + &GithubCredential { + token: None, + source: None, + detail: "GitHub CLI is not authenticated".to_string(), + }, + Some(&RateLimitSnapshot { + credential_scope: "anonymous".to_string(), + limit: Some(60), + remaining: Some(12), + reset_at: None, + observed_at: Instant::now(), + }), + None, + ); + assert!(!anonymous.authenticated); + assert!(anonymous + .detail + .as_deref() + .is_some_and(|detail| detail.contains("12 of 60"))); + } +} diff --git a/src/crates/services/services-integrations/src/miniapp/loopx_workspace.rs b/src/crates/services/services-integrations/src/miniapp/loopx_workspace.rs new file mode 100644 index 0000000000..b2ff7c036b --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp/loopx_workspace.rs @@ -0,0 +1,1554 @@ +//! Local isolated workspace preparation for LoopX tasks. + +use super::loopx_cli::{ + LoopxCommandPlan, LoopxProcessError, LoopxProcessObserver, LoopxProcessRunner, + NoopLoopxProcessObserver, SystemLoopxProcessRunner, +}; +use openbitfun_product_domains::miniapp::loopx as loopx_contract; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; +use url::Url; + +const WORKSPACE_MARKER_SCHEMA: u32 = 1; +const WORKSPACE_MARKER_NAME: &str = "bitfun-loopx-workspace.json"; +static WORKSPACE_PROBE_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +fn git_compatible_path(path: &Path) -> PathBuf { + dunce::simplified(path).to_path_buf() +} + +#[derive(Debug, Clone)] +pub struct LoopxWorkspaceServiceConfig { + pub root_dir: PathBuf, + pub git_executable: PathBuf, + pub clone_deadline: Duration, + pub command_deadline: Duration, + pub terminate_grace: Duration, +} + +impl LoopxWorkspaceServiceConfig { + pub fn new(root_dir: impl Into, git_executable: impl Into) -> Self { + Self { + root_dir: root_dir.into(), + git_executable: git_executable.into(), + // A first-time bare clone downloads the full history of the target + // repository; medium repositories on residential bandwidth + // regularly need more than five minutes. 30 minutes bounds runaway + // transfers without killing an otherwise healthy clone. + clone_deadline: Duration::from_secs(1800), + command_deadline: Duration::from_secs(30), + terminate_grace: Duration::from_secs(2), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoopxWorkspaceLayout { + pub repository_identity: String, + pub repository_hash: String, + pub task_hash: String, + pub worktree_path: PathBuf, + /// Shared bare repository for this repository. All tasks of one repository + /// share a single object database via `git worktree add`; it is removed + /// once the last worktree is disposed. + pub bare_repo_path: PathBuf, + pub registry_path: PathBuf, + pub branch_name: String, + pub clone_url: String, +} + +pub fn plan_workspace_layout( + root_dir: &Path, + task_id: &str, + item: &loopx_contract::LoopxIssueKey, +) -> loopx_contract::LoopxHostResult { + validate_task_and_item("workspace-plan", task_id, item)?; + if !root_dir.is_absolute() { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + "workspace-plan", + "LoopX workspace root must be absolute", + false, + )); + } + let repository_identity = item.repository.canonical_id().to_lowercase(); + let repository_hash = sha256_prefix(repository_identity.as_bytes(), 20); + let task_hash = sha256_prefix(task_id.as_bytes(), 20); + let repository_dir = root_dir.join(&repository_hash); + let worktree_path = repository_dir.join(&task_hash); + Ok(LoopxWorkspaceLayout { + repository_identity, + repository_hash, + task_hash: task_hash.clone(), + bare_repo_path: repository_dir.join("bare.git"), + registry_path: worktree_path.join(".loopx").join("registry.json"), + worktree_path, + branch_name: format!("bitfun-loopx/{task_hash}"), + clone_url: format!( + "https://github.com/{}/{}.git", + item.repository.owner, item.repository.repository + ), + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoopxGitCommandPlan { + pub executable: PathBuf, + pub args: Vec, + pub environment: BTreeMap, + pub current_dir: Option, +} + +pub fn plan_git_clone_command( + git_executable: &Path, + layout: &LoopxWorkspaceLayout, +) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git_executable.to_path_buf(), + args: vec![ + OsString::from("clone"), + OsString::from("--no-checkout"), + OsString::from("--config"), + // The target repository may contain paths beyond the Windows + // 260-character limit once joined with the workspace root; keep + // git capable of long paths inside every created repository. + OsString::from("core.longpaths=true"), + OsString::from("--origin"), + OsString::from("origin"), + OsString::from("--"), + OsString::from(&layout.clone_url), + layout.worktree_path.as_os_str().to_owned(), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +/// Shared bare clone for one repository. All tasks of the repository add +/// linked worktrees from this single object database. +pub fn plan_git_bare_clone_command( + git_executable: &Path, + layout: &LoopxWorkspaceLayout, +) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git_executable.to_path_buf(), + args: vec![ + OsString::from("clone"), + OsString::from("--bare"), + OsString::from("--config"), + // Long-path support must travel with the shared bare repository so + // linked worktree checkouts can write files whose joined path + // exceeds the Windows 260-character limit. + OsString::from("core.longpaths=true"), + OsString::from("--origin"), + OsString::from("origin"), + OsString::from("--"), + OsString::from(&layout.clone_url), + layout.bare_repo_path.as_os_str().to_owned(), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +/// Cheap validity probe for the shared bare repository. A clone that was +/// killed mid-transfer (deadline, crash) leaves a directory with no resolvable +/// HEAD; every later `worktree add` would fail with "invalid reference". +pub fn plan_git_bare_head_check_command( + git_executable: &Path, + layout: &LoopxWorkspaceLayout, +) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git_executable.to_path_buf(), + args: vec![ + OsString::from("-C"), + layout.bare_repo_path.as_os_str().to_owned(), + OsString::from("rev-parse"), + OsString::from("--verify"), + OsString::from("HEAD"), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +/// Adds a linked worktree from the shared bare repository, checking out the +/// task branch. Equivalent to the old per-task full clone + branch checkout +/// but shares the object database across all tasks of the repository. +pub fn plan_git_worktree_add_command( + git_executable: &Path, + layout: &LoopxWorkspaceLayout, +) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git_executable.to_path_buf(), + args: vec![ + OsString::from("-C"), + layout.bare_repo_path.as_os_str().to_owned(), + OsString::from("worktree"), + OsString::from("add"), + OsString::from("-b"), + OsString::from(&layout.branch_name), + layout.worktree_path.as_os_str().to_owned(), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +/// Removes a task worktree (and its registration) from the shared bare repo. +pub fn plan_git_worktree_remove_command( + git_executable: &Path, + layout: &LoopxWorkspaceLayout, +) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git_executable.to_path_buf(), + args: vec![ + OsString::from("-C"), + layout.bare_repo_path.as_os_str().to_owned(), + OsString::from("worktree"), + OsString::from("remove"), + OsString::from("--force"), + layout.worktree_path.as_os_str().to_owned(), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +/// Porcelain worktree list of the shared bare repo. Used after dispose to +/// decide whether the last worktree is gone and the bare repo can be deleted. +pub fn plan_git_worktree_list_command( + git_executable: &Path, + layout: &LoopxWorkspaceLayout, +) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git_executable.to_path_buf(), + args: vec![ + OsString::from("-C"), + layout.bare_repo_path.as_os_str().to_owned(), + OsString::from("worktree"), + OsString::from("list"), + OsString::from("--porcelain"), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +pub fn canonical_github_remote(remote: &str) -> Option { + let trimmed = remote.trim().trim_end_matches('/'); + if let Some(path) = trimmed.strip_prefix("git@github.com:") { + return canonical_github_path(path); + } + let parsed = Url::parse(trimmed).ok()?; + if !parsed.host_str()?.eq_ignore_ascii_case("github.com") { + return None; + } + canonical_github_path(parsed.path().trim_start_matches('/')) +} + +pub struct LoopxWorkspaceService { + config: LoopxWorkspaceServiceConfig, + runner: Arc, + observer: Arc, + mutation_lock: Mutex<()>, + running: Arc>>, +} + +impl std::fmt::Debug for LoopxWorkspaceService { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("LoopxWorkspaceService") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +impl LoopxWorkspaceService { + pub fn new(config: LoopxWorkspaceServiceConfig) -> Self { + Self::with_runner( + config, + Arc::new(SystemLoopxProcessRunner), + Arc::new(NoopLoopxProcessObserver), + ) + } + + pub fn with_runner( + config: LoopxWorkspaceServiceConfig, + runner: Arc, + observer: Arc, + ) -> Self { + Self { + config, + runner, + observer, + mutation_lock: Mutex::new(()), + running: Arc::new(StdMutex::new(HashMap::new())), + } + } + + async fn probe_inner( + &self, + request: &loopx_contract::LoopxWorkspaceProbeRequest, + cancellation: CancellationToken, + ) -> loopx_contract::LoopxHostResult { + if request.operation_id.trim().is_empty() { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + &request.operation_id, + "operation_id is required", + false, + )); + } + tokio::fs::create_dir_all(&self.config.root_dir) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to create LoopX workspace root: {error}"), + true, + ) + })?; + let canonical_root = tokio::fs::canonicalize(&self.config.root_dir) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to resolve LoopX workspace root: {error}"), + true, + ) + })?; + verify_workspace_root_writable(&canonical_root, &request.operation_id).await?; + + let version = self + .run_git( + &request.operation_id, + git_version_plan(&self.config.git_executable), + self.config.command_deadline, + cancellation.clone(), + ) + .await?; + let git_version = version.stdout.trim().to_string(); + + let repository_verified = if let Some(repository) = &request.repository { + validate_repository(&request.operation_id, repository)?; + self.run_git( + &request.operation_id, + git_repository_probe_plan(&self.config.git_executable, repository), + self.config.command_deadline, + cancellation, + ) + .await?; + true + } else { + false + }; + + Ok(loopx_contract::LoopxWorkspaceProbeResult { + git_version: (!git_version.is_empty()).then_some(git_version), + workspace_root: git_compatible_path(&canonical_root) + .to_string_lossy() + .into_owned(), + repository_verified, + }) + } + + async fn prepare_inner( + &self, + request: &loopx_contract::LoopxWorkspacePrepareRequest, + cancellation: CancellationToken, + ) -> loopx_contract::LoopxHostResult { + validate_task_and_item(&request.operation_id, &request.task_id, &request.item)?; + tokio::fs::create_dir_all(&self.config.root_dir) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to create LoopX workspace root: {error}"), + true, + ) + })?; + let canonical_root = tokio::fs::canonicalize(&self.config.root_dir) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to resolve LoopX workspace root: {error}"), + true, + ) + })?; + let layout = plan_workspace_layout( + &git_compatible_path(&canonical_root), + &request.task_id, + &request.item, + )?; + let repository_parent = layout + .worktree_path + .parent() + .expect("hashed workspace layout always has a parent"); + tokio::fs::create_dir_all(repository_parent) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to create repository workspace directory: {error}"), + true, + ) + })?; + ensure_path_boundary(&canonical_root, repository_parent, &request.operation_id).await?; + + if tokio::fs::try_exists(&layout.worktree_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + error.to_string(), + true, + ) + })? + { + ensure_path_boundary( + &canonical_root, + &layout.worktree_path, + &request.operation_id, + ) + .await?; + let marker = read_workspace_marker(&layout, &request.operation_id).await?; + validate_marker(&marker, &layout, &request.operation_id)?; + self.verify_remote(&layout, &request.operation_id, cancellation) + .await?; + return Ok(workspace_result(&layout, true)); + } + + // Shared-object-database layout: ensure the bare repository exists once + // per repository, then add a linked worktree for this task. Older + // per-task full clones on disk keep working (they take the reuse path + // above); only brand-new workspaces get the shared bare repo. + let bare_exists = tokio::fs::try_exists(&layout.bare_repo_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to probe shared bare repository: {error}"), + true, + ) + })?; + // A bare repository abandoned by an interrupted clone has no resolvable + // HEAD and would poison every task of this repository with + // "fatal: invalid reference: HEAD" at worktree add. Rebuild it from + // scratch, but only while no sibling task worktree still links into it: + // removing a shared object database out from under live worktrees would + // silently break them. + let bare_usable = if bare_exists { + let head_check = self + .run_git( + &request.operation_id, + plan_git_bare_head_check_command(&self.config.git_executable, &layout), + self.config.command_deadline, + cancellation.clone(), + ) + .await; + match head_check { + Ok(_) => true, + Err(_) => { + let linked_worktrees = self + .run_git( + &request.operation_id, + plan_git_worktree_list_command(&self.config.git_executable, &layout), + self.config.command_deadline, + cancellation.clone(), + ) + .await + .map(|listing| { + listing + .stdout + .lines() + .filter(|line| line.starts_with("worktree ")) + .count() + // The first porcelain entry is the bare + // repository itself. + .saturating_sub(1) + }) + // An unlistable bare repository cannot serve any + // worktree either; treat it as abandoned. + .unwrap_or(0); + if linked_worktrees > 0 { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + &request.operation_id, + format!( + "shared bare repository {} has no valid HEAD (an earlier clone was interrupted) but still hosts {linked_worktrees} task worktree(s); archive those tasks so it can be rebuilt", + layout.bare_repo_path.display() + ), + false, + )); + } + tokio::fs::remove_dir_all(&layout.bare_repo_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!( + "failed to remove corrupt shared bare repository left by an interrupted clone: {error}" + ), + true, + ) + })?; + false + } + } + } else { + false + }; + if !bare_usable { + self.run_git( + &request.operation_id, + plan_git_bare_clone_command(&self.config.git_executable, &layout), + self.config.clone_deadline, + cancellation.clone(), + ) + .await?; + } + self.run_git( + &request.operation_id, + plan_git_worktree_add_command(&self.config.git_executable, &layout), + self.config.clone_deadline, + cancellation.clone(), + ) + .await?; + self.verify_remote(&layout, &request.operation_id, cancellation.clone()) + .await?; + write_workspace_marker(&layout, &request.task_id, &request.operation_id).await?; + Ok(workspace_result(&layout, false)) + } + + async fn verify_inner( + &self, + request: &loopx_contract::LoopxWorkspaceVerifyRequest, + cancellation: CancellationToken, + ) -> loopx_contract::LoopxHostResult { + validate_task_and_item(&request.operation_id, &request.task_id, &request.item)?; + let canonical_root = match tokio::fs::canonicalize(&self.config.root_dir).await { + Ok(path) => path, + Err(error) => { + return Ok(invalid_workspace(format!( + "workspace root is unavailable: {error}" + ))) + } + }; + let layout = plan_workspace_layout( + &git_compatible_path(&canonical_root), + &request.task_id, + &request.item, + )?; + if Path::new(&request.worktree_path) != layout.worktree_path + || Path::new(&request.registry_path) != layout.registry_path + { + return Ok(invalid_workspace( + "workspace paths do not match the canonical task layout", + )); + } + if ensure_path_boundary( + &canonical_root, + &layout.worktree_path, + &request.operation_id, + ) + .await + .is_err() + { + return Ok(invalid_workspace( + "workspace resolves outside the managed root", + )); + } + let marker = match read_workspace_marker(&layout, &request.operation_id).await { + Ok(marker) => marker, + Err(error) => return Ok(invalid_workspace(error.message)), + }; + if let Err(error) = validate_marker(&marker, &layout, &request.operation_id) { + return Ok(invalid_workspace(error.message)); + } + if let Err(error) = self + .verify_remote(&layout, &request.operation_id, cancellation) + .await + { + if matches!( + error.kind, + loopx_contract::LoopxHostPortErrorKind::Cancelled + | loopx_contract::LoopxHostPortErrorKind::Timeout + ) { + return Err(error); + } + return Ok(invalid_workspace(error.message)); + } + Ok(loopx_contract::LoopxWorkspaceVerifyResult { + valid: true, + repository: Some(request.item.repository.clone()), + message: None, + }) + } + + async fn verify_remote( + &self, + layout: &LoopxWorkspaceLayout, + operation_id: &str, + cancellation: CancellationToken, + ) -> loopx_contract::LoopxHostResult<()> { + let output = self + .run_git( + operation_id, + git_remote_plan(&self.config.git_executable, layout), + self.config.command_deadline, + cancellation, + ) + .await?; + let actual = canonical_github_remote(output.stdout.trim()).ok_or_else(|| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + "workspace origin is not a canonical GitHub repository", + false, + ) + })?; + if actual != layout.repository_identity { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + format!( + "workspace origin mismatch: expected {}, got {actual}; existing data was preserved", + layout.repository_identity + ), + false, + )); + } + Ok(()) + } + + async fn run_git( + &self, + operation_id: &str, + plan: LoopxGitCommandPlan, + deadline: Duration, + cancellation: CancellationToken, + ) -> loopx_contract::LoopxHostResult { + self.runner + .run( + LoopxCommandPlan { + operation_id: operation_id.to_string(), + executable: plan.executable, + args: plan.args, + current_dir: plan.current_dir, + environment: plan.environment, + deadline, + terminate_grace: self.config.terminate_grace, + }, + cancellation, + self.observer.as_ref(), + ) + .await + .map_err(|error| map_process_error(error, operation_id)) + } + + fn register_operation( + &self, + operation_id: &str, + ) -> loopx_contract::LoopxHostResult<(CancellationToken, WorkspaceOperationRegistration)> { + if operation_id.trim().is_empty() { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + operation_id, + "operation_id is required", + false, + )); + } + let mut running = self + .running + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if running.contains_key(operation_id) { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + "workspace operation is already running", + true, + )); + } + let cancellation = CancellationToken::new(); + running.insert(operation_id.to_string(), cancellation.clone()); + Ok(( + cancellation, + WorkspaceOperationRegistration { + operation_id: operation_id.to_string(), + running: self.running.clone(), + }, + )) + } + + /// Removes the task worktree after terminal settlement, then removes the + /// shared bare repository once its last linked worktree is gone. + /// + /// - Shared layout: `git worktree remove --force` (linked worktree), then + /// `git worktree list --porcelain`; when only the bare repository itself + /// remains, the bare directory is removed too. + /// - Legacy layout (per-task full clone, `.git` is a directory): the whole + /// worktree directory is removed directly. Upgraded installs keep their + /// existing clones working; only new workspaces use the shared layout. + async fn dispose_inner( + &self, + request: &loopx_contract::LoopxWorkspaceDisposeRequest, + cancellation: CancellationToken, + ) -> loopx_contract::LoopxHostResult { + validate_task_and_item(&request.operation_id, &request.task_id, &request.item)?; + let canonical_root = tokio::fs::canonicalize(&self.config.root_dir) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to resolve LoopX workspace root: {error}"), + true, + ) + })?; + let layout = plan_workspace_layout( + &git_compatible_path(&canonical_root), + &request.task_id, + &request.item, + )?; + let bare_exists = tokio::fs::try_exists(&layout.bare_repo_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to probe bare repository: {error}"), + true, + ) + })?; + + if tokio::fs::try_exists(&layout.worktree_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + error.to_string(), + true, + ) + })? + { + ensure_path_boundary( + &canonical_root, + &layout.worktree_path, + &request.operation_id, + ) + .await?; + // Only BitFun-owned workspaces may be removed. A missing or + // mismatched marker keeps the directory (and any user changes). + let marker = read_workspace_marker(&layout, &request.operation_id).await?; + validate_marker(&marker, &layout, &request.operation_id)?; + + let dot_git = layout.worktree_path.join(".git"); + let linked_worktree = tokio::fs::metadata(&dot_git) + .await + .map(|metadata| metadata.is_file()) + .unwrap_or(false); + + if linked_worktree && bare_exists { + self.run_git( + &request.operation_id, + plan_git_worktree_remove_command(&self.config.git_executable, &layout), + self.config.command_deadline, + cancellation.clone(), + ) + .await?; + } else { + tokio::fs::remove_dir_all(&layout.worktree_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to remove task worktree: {error}"), + false, + ) + })?; + } + } + + // Remove the shared bare repository once no linked worktree remains. + if bare_exists { + let listing = self + .run_git( + &request.operation_id, + plan_git_worktree_list_command(&self.config.git_executable, &layout), + self.config.command_deadline, + cancellation, + ) + .await?; + let worktree_entries = listing + .stdout + .lines() + .filter(|line| line.starts_with("worktree ")) + .count(); + if worktree_entries <= 1 { + tokio::fs::remove_dir_all(&layout.bare_repo_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to remove shared bare repository: {error}"), + false, + ) + })?; + } + } + Ok(loopx_contract::LoopxWorkspaceDisposeResult { removed: true }) + } + + async fn reset_inner( + &self, + request: &loopx_contract::LoopxWorkspaceResetRequest, + ) -> loopx_contract::LoopxHostResult { + if request.operation_id.trim().is_empty() { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + &request.operation_id, + "operation_id is required", + false, + )); + } + let root_metadata = match tokio::fs::symlink_metadata(&self.config.root_dir).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(loopx_contract::LoopxWorkspaceResetResult { removed: false }); + } + Err(error) => { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to inspect LoopX workspace root: {error}"), + true, + )); + } + }; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + &request.operation_id, + "LoopX workspace reset refused to remove a non-directory or symlink root", + false, + )); + } + let canonical_root = tokio::fs::canonicalize(&self.config.root_dir) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to resolve LoopX workspace root: {error}"), + true, + ) + })?; + if !canonical_root.is_absolute() || canonical_root.parent().is_none() { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + &request.operation_id, + "LoopX workspace reset refused an unsafe root path", + false, + )); + } + let root_name = canonical_root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if !root_name.eq_ignore_ascii_case("workspaces") + && !root_name.eq_ignore_ascii_case("loopx-workspaces") + { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + &request.operation_id, + "LoopX workspace reset refused an unexpected root directory name", + false, + )); + } + let parent = canonical_root + .parent() + .expect("validated workspace root parent"); + let detached_root = parent.join(format!(".{root_name}.reset-{}", uuid::Uuid::new_v4())); + tokio::fs::rename(&canonical_root, &detached_root) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to detach LoopX workspace root for cleanup: {error}"), + true, + ) + })?; + if let Err(error) = tokio::fs::create_dir_all(&canonical_root).await { + let _ = tokio::fs::rename(&detached_root, &canonical_root).await; + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + &request.operation_id, + format!("failed to recreate LoopX workspace root: {error}"), + true, + )); + } + + let retained = self + .restore_bare_repository_caches(&detached_root, &canonical_root, &request.operation_id) + .await; + if let Err(error) = &retained { + log::warn!("LoopX repository cache retention failed during reset: {error}"); + } + let prune_paths = retained.unwrap_or_default(); + for bare_repo_path in prune_paths { + if let Err(error) = self + .run_git( + &request.operation_id, + git_worktree_prune_plan(&self.config.git_executable, &bare_repo_path), + self.config.command_deadline, + CancellationToken::new(), + ) + .await + { + log::warn!( + "Failed to prune retained LoopX worktree registrations: bare_repo={}, error={}", + bare_repo_path.display(), + error + ); + } + } + + tokio::spawn(async move { + match tokio::fs::remove_dir_all(&detached_root).await { + Ok(()) => log::info!( + "Detached LoopX task workspaces were reclaimed in the background: path={}", + detached_root.display() + ), + Err(error) => log::warn!( + "Failed to reclaim detached LoopX task workspaces: path={}, error={}", + detached_root.display(), + error + ), + } + }); + Ok(loopx_contract::LoopxWorkspaceResetResult { removed: true }) + } + + async fn restore_bare_repository_caches( + &self, + detached_root: &Path, + fresh_root: &Path, + operation_id: &str, + ) -> loopx_contract::LoopxHostResult> { + let mut retained = Vec::new(); + let mut repositories = tokio::fs::read_dir(detached_root).await.map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to enumerate detached LoopX repositories: {error}"), + true, + ) + })?; + while let Some(entry) = repositories.next_entry().await.map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to inspect detached LoopX repository: {error}"), + true, + ) + })? { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if !is_repository_hash(name) + || !entry + .file_type() + .await + .map(|kind| kind.is_dir()) + .unwrap_or(false) + { + continue; + } + let source = entry.path().join("bare.git"); + let Ok(metadata) = tokio::fs::symlink_metadata(&source).await else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + continue; + } + let destination_dir = fresh_root.join(name); + tokio::fs::create_dir_all(&destination_dir) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to recreate LoopX repository cache directory: {error}"), + true, + ) + })?; + let destination = destination_dir.join("bare.git"); + tokio::fs::rename(&source, &destination) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to retain LoopX bare repository cache: {error}"), + true, + ) + })?; + retained.push(destination); + } + Ok(retained) + } +} + +impl loopx_contract::LoopxWorkspacePort for LoopxWorkspaceService { + fn probe( + &self, + request: loopx_contract::LoopxWorkspaceProbeRequest, + ) -> loopx_contract::LoopxHostFuture<'_, loopx_contract::LoopxWorkspaceProbeResult> { + Box::pin(async move { + let (cancellation, _registration) = self.register_operation(&request.operation_id)?; + self.probe_inner(&request, cancellation).await + }) + } + + fn prepare( + &self, + request: loopx_contract::LoopxWorkspacePrepareRequest, + ) -> loopx_contract::LoopxHostFuture<'_, loopx_contract::LoopxWorkspacePrepareResult> { + Box::pin(async move { + let (cancellation, _registration) = self.register_operation(&request.operation_id)?; + let _mutation = self.mutation_lock.lock().await; + self.prepare_inner(&request, cancellation).await + }) + } + + fn verify( + &self, + request: loopx_contract::LoopxWorkspaceVerifyRequest, + ) -> loopx_contract::LoopxHostFuture<'_, loopx_contract::LoopxWorkspaceVerifyResult> { + Box::pin(async move { + let (cancellation, _registration) = self.register_operation(&request.operation_id)?; + self.verify_inner(&request, cancellation).await + }) + } + + fn cancel( + &self, + request: loopx_contract::LoopxWorkspaceCancelRequest, + ) -> loopx_contract::LoopxHostFuture<'_, loopx_contract::LoopxWorkspaceCancelResult> { + Box::pin(async move { + let running = self + .running + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let cancelled = if let Some(cancellation) = running.get(&request.target_operation_id) { + cancellation.cancel(); + true + } else { + false + }; + Ok(loopx_contract::LoopxWorkspaceCancelResult { + target_operation_id: request.target_operation_id, + cancelled, + }) + }) + } + + fn dispose( + &self, + request: loopx_contract::LoopxWorkspaceDisposeRequest, + ) -> loopx_contract::LoopxHostFuture<'_, loopx_contract::LoopxWorkspaceDisposeResult> { + Box::pin(async move { + let (cancellation, _registration) = self.register_operation(&request.operation_id)?; + let _mutation = self.mutation_lock.lock().await; + self.dispose_inner(&request, cancellation).await + }) + } + + fn reset( + &self, + request: loopx_contract::LoopxWorkspaceResetRequest, + ) -> loopx_contract::LoopxHostFuture<'_, loopx_contract::LoopxWorkspaceResetResult> { + Box::pin(async move { + let _mutation = self.mutation_lock.lock().await; + self.reset_inner(&request).await + }) + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct LoopxWorkspaceMarker { + schema_version: u32, + repository_identity: String, + repository_hash: String, + task_id_hash: String, + branch_name: String, +} + +struct WorkspaceOperationRegistration { + operation_id: String, + running: Arc>>, +} + +impl Drop for WorkspaceOperationRegistration { + fn drop(&mut self) { + self.running + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .remove(&self.operation_id); + } +} + +fn git_noninteractive_environment() -> BTreeMap { + BTreeMap::from([ + (OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0")), + (OsString::from("GCM_INTERACTIVE"), OsString::from("Never")), + ]) +} + +fn git_version_plan(git: &Path) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git.to_path_buf(), + args: vec![OsString::from("--version")], + environment: BTreeMap::new(), + current_dir: None, + } +} + +fn git_worktree_prune_plan(git: &Path, bare_repo_path: &Path) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git.to_path_buf(), + args: vec![ + OsString::from("--git-dir"), + bare_repo_path.as_os_str().to_owned(), + OsString::from("worktree"), + OsString::from("prune"), + OsString::from("--expire"), + OsString::from("now"), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +fn is_repository_hash(value: &str) -> bool { + value.len() == 20 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn git_repository_probe_plan( + git: &Path, + repository: &loopx_contract::LoopxRepositoryKey, +) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git.to_path_buf(), + args: vec![ + OsString::from("ls-remote"), + OsString::from("--exit-code"), + OsString::from("--"), + OsString::from(format!( + "https://github.com/{}/{}.git", + repository.owner, repository.repository + )), + OsString::from("HEAD"), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +fn git_remote_plan(git: &Path, layout: &LoopxWorkspaceLayout) -> LoopxGitCommandPlan { + LoopxGitCommandPlan { + executable: git.to_path_buf(), + args: vec![ + OsString::from("-C"), + layout.worktree_path.as_os_str().to_owned(), + OsString::from("config"), + OsString::from("--get"), + OsString::from("remote.origin.url"), + ], + environment: git_noninteractive_environment(), + current_dir: None, + } +} + +/// Resolves the real Git metadata directory for a worktree path. +/// +/// A standalone clone keeps `.git` as a directory; a linked worktree from a +/// shared bare repository keeps `.git` as a file containing +/// `gitdir: `. The ownership marker must live inside the real +/// gitdir so both layouts stay covered. +async fn resolve_git_dir( + worktree_path: &Path, + operation_id: &str, +) -> loopx_contract::LoopxHostResult { + let dot_git = worktree_path.join(".git"); + let metadata = tokio::fs::metadata(&dot_git).await.map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + format!( + "existing workspace has no usable .git entry: {error}; existing data was preserved" + ), + false, + ) + })?; + if metadata.is_dir() { + return Ok(dot_git); + } + // Linked worktree: .git is a gitdir pointer file. + let pointer = tokio::fs::read_to_string(&dot_git).await.map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + format!("failed to read worktree gitdir pointer: {error}"), + false, + ) + })?; + let gitdir = pointer + .lines() + .find_map(|line| line.strip_prefix("gitdir:").map(str::trim)) + .map(PathBuf::from) + .ok_or_else(|| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + "worktree .git pointer has no gitdir entry", + false, + ) + })?; + Ok(gitdir) +} + +async fn read_workspace_marker( + layout: &LoopxWorkspaceLayout, + operation_id: &str, +) -> loopx_contract::LoopxHostResult { + let git_dir = resolve_git_dir(&layout.worktree_path, operation_id).await?; + let marker_path = git_dir.join(WORKSPACE_MARKER_NAME); + let raw = tokio::fs::read(&marker_path).await.map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + format!( + "existing workspace has no valid BitFun ownership marker: {error}; existing data was preserved" + ), + false, + ) + })?; + serde_json::from_slice(&raw).map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + format!( + "existing workspace ownership marker is invalid: {error}; existing data was preserved" + ), + false, + ) + }) +} + +async fn write_workspace_marker( + layout: &LoopxWorkspaceLayout, + task_id: &str, + operation_id: &str, +) -> loopx_contract::LoopxHostResult<()> { + let marker = LoopxWorkspaceMarker { + schema_version: WORKSPACE_MARKER_SCHEMA, + repository_identity: layout.repository_identity.clone(), + repository_hash: layout.repository_hash.clone(), + task_id_hash: sha256_prefix(task_id.as_bytes(), 20), + branch_name: layout.branch_name.clone(), + }; + let git_dir = resolve_git_dir(&layout.worktree_path, operation_id).await?; + let marker_path = git_dir.join(WORKSPACE_MARKER_NAME); + let temporary_path = git_dir.join(format!("{WORKSPACE_MARKER_NAME}.tmp")); + let encoded = serde_json::to_vec_pretty(&marker).map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + error.to_string(), + false, + ) + })?; + tokio::fs::write(&temporary_path, encoded) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to write workspace ownership marker: {error}"), + true, + ) + })?; + tokio::fs::rename(&temporary_path, &marker_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to publish workspace ownership marker: {error}"), + true, + ) + })?; + Ok(()) +} + +fn validate_marker( + marker: &LoopxWorkspaceMarker, + layout: &LoopxWorkspaceLayout, + operation_id: &str, +) -> loopx_contract::LoopxHostResult<()> { + if marker.schema_version != WORKSPACE_MARKER_SCHEMA + || marker.repository_identity != layout.repository_identity + || marker.repository_hash != layout.repository_hash + || marker.task_id_hash != layout.task_hash + || marker.branch_name != layout.branch_name + { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + "existing workspace ownership does not match the requested task; existing data was preserved", + false, + )); + } + Ok(()) +} + +async fn ensure_path_boundary( + canonical_root: &Path, + path: &Path, + operation_id: &str, +) -> loopx_contract::LoopxHostResult<()> { + let canonical = tokio::fs::canonicalize(path).await.map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to resolve workspace path: {error}"), + true, + ) + })?; + if !canonical.starts_with(canonical_root) { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::Conflict, + operation_id, + "workspace path resolves outside the managed root", + false, + )); + } + Ok(()) +} + +fn validate_task_and_item( + operation_id: &str, + task_id: &str, + item: &loopx_contract::LoopxIssueKey, +) -> loopx_contract::LoopxHostResult<()> { + if operation_id.trim().is_empty() || task_id.trim().is_empty() { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + operation_id, + "operation_id and task_id are required", + false, + )); + } + validate_repository(operation_id, &item.repository)?; + if item.number == 0 { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + operation_id, + "workspace preparation requires a canonical GitHub item", + false, + )); + } + Ok(()) +} + +fn validate_repository( + operation_id: &str, + repository: &loopx_contract::LoopxRepositoryKey, +) -> loopx_contract::LoopxHostResult<()> { + if !repository.host.eq_ignore_ascii_case("github.com") + || !is_github_slug(&repository.owner) + || !is_github_slug(&repository.repository) + { + return Err(host_error( + loopx_contract::LoopxHostPortErrorKind::InvalidInput, + operation_id, + "workspace preparation requires a canonical GitHub repository", + false, + )); + } + Ok(()) +} + +async fn verify_workspace_root_writable( + canonical_root: &Path, + operation_id: &str, +) -> loopx_contract::LoopxHostResult<()> { + let probe_path = canonical_root.join(format!( + ".loopx-write-probe-{}-{}", + std::process::id(), + WORKSPACE_PROBE_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + let file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe_path) + .await + .map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("LoopX workspace root is not writable: {error}"), + false, + ) + })?; + drop(file); + tokio::fs::remove_file(&probe_path).await.map_err(|error| { + host_error( + loopx_contract::LoopxHostPortErrorKind::Io, + operation_id, + format!("failed to clean up workspace write probe: {error}"), + true, + ) + }) +} + +fn is_github_slug(value: &str) -> bool { + !value.is_empty() + && value.len() <= 100 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn canonical_github_path(path: &str) -> Option { + let path = path.strip_suffix(".git").unwrap_or(path); + let mut segments = path.split('/'); + let owner = segments.next()?; + let repository = segments.next()?; + if segments.next().is_some() || !is_github_slug(owner) || !is_github_slug(repository) { + return None; + } + Some(format!("github.com/{owner}/{repository}").to_lowercase()) +} + +fn sha256_prefix(value: &[u8], length: usize) -> String { + let digest = hex::encode(Sha256::digest(value)); + digest[..length].to_string() +} + +fn workspace_result( + layout: &LoopxWorkspaceLayout, + reused: bool, +) -> loopx_contract::LoopxWorkspacePrepareResult { + loopx_contract::LoopxWorkspacePrepareResult { + worktree_path: layout.worktree_path.to_string_lossy().into_owned(), + registry_path: layout.registry_path.to_string_lossy().into_owned(), + reused, + repository_verified: true, + } +} + +fn invalid_workspace(message: impl Into) -> loopx_contract::LoopxWorkspaceVerifyResult { + loopx_contract::LoopxWorkspaceVerifyResult { + valid: false, + repository: None, + message: Some(message.into()), + } +} + +fn map_process_error( + error: LoopxProcessError, + operation_id: &str, +) -> loopx_contract::LoopxHostPortError { + let (kind, retryable) = match &error { + LoopxProcessError::Cancelled { .. } => { + (loopx_contract::LoopxHostPortErrorKind::Cancelled, true) + } + LoopxProcessError::Timeout { .. } => { + (loopx_contract::LoopxHostPortErrorKind::Timeout, true) + } + LoopxProcessError::Io { .. } => (loopx_contract::LoopxHostPortErrorKind::Io, true), + _ => (loopx_contract::LoopxHostPortErrorKind::Backend, true), + }; + host_error( + kind, + operation_id, + format_workspace_process_error(&error), + retryable, + ) +} + +fn format_workspace_process_error(error: &LoopxProcessError) -> String { + let stderr_tail: &[String] = match error { + LoopxProcessError::Exited { stderr_tail, .. } + | LoopxProcessError::Timeout { stderr_tail, .. } + | LoopxProcessError::Cancelled { stderr_tail } => stderr_tail, + _ => &[], + }; + let detail = stderr_tail + .iter() + .rev() + .find(|line| !line.trim().is_empty()) + .map(|line| line.trim()) + .unwrap_or_default(); + let summary = match error { + LoopxProcessError::Exited { code, .. } => { + format!("workspace Git command exited with status {code:?}") + } + LoopxProcessError::Start { message } => { + format!("workspace Git command could not start: {message}") + } + LoopxProcessError::Io { message } => { + format!("workspace Git command IO failed: {message}") + } + LoopxProcessError::Timeout { deadline_ms, .. } => { + format!("workspace Git command timed out after {deadline_ms} ms") + } + LoopxProcessError::Cancelled { .. } => "workspace Git command was cancelled".to_string(), + LoopxProcessError::OutputLimit { limit_bytes } => { + format!("workspace Git output exceeded {limit_bytes} bytes") + } + }; + if detail.is_empty() { + summary + } else { + format!("{summary}: {detail}") + } +} + +fn host_error( + kind: loopx_contract::LoopxHostPortErrorKind, + operation_id: &str, + message: impl Into, + retryable: bool, +) -> loopx_contract::LoopxHostPortError { + loopx_contract::LoopxHostPortError { + kind, + message: message.into(), + operation_id: Some(operation_id.to_string()), + retryable, + } +} diff --git a/src/crates/services/services-integrations/src/miniapp/mod.rs b/src/crates/services/services-integrations/src/miniapp/mod.rs index 0b72d5f3ce..7901621009 100644 --- a/src/crates/services/services-integrations/src/miniapp/mod.rs +++ b/src/crates/services/services-integrations/src/miniapp/mod.rs @@ -1,7 +1,18 @@ //! MiniApp concrete integration services. +#[cfg(feature = "miniapp-runtime")] pub mod builtin_io; +#[cfg(feature = "miniapp-runtime")] pub mod host_dispatch; +#[cfg(feature = "miniapp-loopx")] +pub mod loopx_cli; +#[cfg(feature = "miniapp-loopx")] +pub mod loopx_github; +#[cfg(feature = "miniapp-loopx")] +pub mod loopx_workspace; +#[cfg(feature = "miniapp-runtime")] pub mod storage; +#[cfg(feature = "miniapp-runtime")] pub mod worker; +#[cfg(feature = "miniapp-runtime")] pub mod worker_pool; diff --git a/src/crates/services/services-integrations/src/miniapp/worker.rs b/src/crates/services/services-integrations/src/miniapp/worker.rs index 3f24ed2078..95662a81fa 100644 --- a/src/crates/services/services-integrations/src/miniapp/worker.rs +++ b/src/crates/services/services-integrations/src/miniapp/worker.rs @@ -1,4 +1,4 @@ -//! JS Worker — single child process (Bun/Node) with stdin/stderr JSON-RPC. +//! JS Worker —single child process (Bun/Node) with stdin/stderr JSON-RPC. use openbitfun_product_domains::miniapp::runtime::DetectedRuntime; use serde_json::Value; @@ -42,9 +42,12 @@ pub struct JsWorker { impl JsWorker { /// Spawn Worker process: `runtime_path worker_host_path ''` with cwd = app_dir. /// The `app_id` is used as the source identifier when emitting worker events. + /// `resource_dir`, when present, is exported to the worker as `BITFUN_RESOURCE_DIR` + /// so bundled product resources (e.g. the LoopX CLI) resolve on any host. pub async fn spawn( runtime: &DetectedRuntime, worker_host_path: &Path, + resource_dir: Option<&Path>, app_dir: &Path, policy_json: &str, app_id: String, @@ -52,14 +55,19 @@ impl JsWorker { ) -> Result { let exe = runtime.path.to_string_lossy(); let host = worker_host_path.to_string_lossy(); - let mut child = openbitfun_services_core::process_manager::create_tokio_command(&*exe) + let mut command = openbitfun_services_core::process_manager::create_tokio_command(&*exe); + command .arg(&*host) .arg(policy_json) .current_dir(app_dir) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) + .kill_on_drop(true); + if let Some(dir) = resource_dir { + command.env("BITFUN_RESOURCE_DIR", dir); + } + let mut child = command .spawn() .map_err(|e| format!("Failed to spawn JS Worker: {}", e))?; @@ -98,7 +106,7 @@ impl JsWorker { Err(_) => continue, }; - // Lines with an `id` are RPC responses — route to the pending map. + // Lines with an `id` are RPC responses —route to the pending map. let id = msg.get("id").and_then(Value::as_str).map(String::from); if let Some(id) = id { let result = if let Some(err) = msg.get("error") { diff --git a/src/crates/services/services-integrations/src/miniapp/worker_pool.rs b/src/crates/services/services-integrations/src/miniapp/worker_pool.rs index 01d60dd5f8..f5424484f8 100644 --- a/src/crates/services/services-integrations/src/miniapp/worker_pool.rs +++ b/src/crates/services/services-integrations/src/miniapp/worker_pool.rs @@ -173,6 +173,9 @@ pub struct JsWorkerPool { runtime: DetectedRuntime, worker_host_path: PathBuf, miniapps_dir: PathBuf, + /// Directory of host-bundled sidecar resources, exported to every worker + /// as `BITFUN_RESOURCE_DIR` (None in host-less tests and minimal hosts). + resource_dir: Option, event_sink: Option, } @@ -180,6 +183,7 @@ impl JsWorkerPool { pub fn new( miniapps_dir: PathBuf, worker_host_path: PathBuf, + resource_dir: Option, event_sink: Option, ) -> MiniAppWorkerPoolResult { let runtime = detect_runtime().ok_or_else(|| { @@ -190,6 +194,7 @@ impl JsWorkerPool { Ok(Self::from_runtime( miniapps_dir, worker_host_path, + resource_dir, runtime, event_sink, )) @@ -198,6 +203,7 @@ impl JsWorkerPool { pub fn from_runtime( miniapps_dir: PathBuf, worker_host_path: PathBuf, + resource_dir: Option, runtime: DetectedRuntime, event_sink: Option, ) -> Self { @@ -209,6 +215,7 @@ impl JsWorkerPool { runtime, worker_host_path, miniapps_dir, + resource_dir, event_sink, } } @@ -308,6 +315,7 @@ impl JsWorkerPool { let worker = JsWorker::spawn( &self.runtime, &self.worker_host_path, + self.resource_dir.as_deref(), app_dir, policy_json, app_id.to_string(), @@ -538,6 +546,7 @@ mod tests { let pool = JsWorkerPool::from_runtime( miniapps_dir, PathBuf::from("worker-host.js"), + None, DetectedRuntime { kind: RuntimeKind::Node, path: PathBuf::from("node"), @@ -578,6 +587,7 @@ mod tests { let pool = JsWorkerPool::from_runtime( miniapps_dir, PathBuf::from("worker-host.js"), + None, DetectedRuntime { kind: RuntimeKind::Node, path: PathBuf::from("node"), diff --git a/src/crates/services/services-integrations/src/web_tools.rs b/src/crates/services/services-integrations/src/web_tools.rs index cfd5e95dfc..0f3ac619a7 100644 --- a/src/crates/services/services-integrations/src/web_tools.rs +++ b/src/crates/services/services-integrations/src/web_tools.rs @@ -23,6 +23,9 @@ use std::time::Duration; use thiserror::Error; const USER_AGENT_VALUE: &str = "OpenBitFun/1.0"; +const WEB_FETCH_ACCEPT_VALUE: &str = + "text/html,application/xhtml+xml,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.5"; +const WEB_FETCH_ACCEPT_LANGUAGE_VALUE: &str = "en-US,en;q=0.9"; const WEB_FETCH_TIMEOUT_SECS: u64 = 30; const EXA_URL: &str = "https://mcp.exa.ai/mcp"; const EXA_TIMEOUT_SECS: u64 = 60; @@ -39,7 +42,14 @@ pub enum WebToolNetworkError { #[error("Failed to fetch URL: {0}")] Fetch(String), #[error("HTTP error {status}: {reason}")] - HttpStatus { status: String, reason: String }, + HttpStatus { + status_code: u16, + status: String, + reason: String, + retry_after: Option, + rate_limit_remaining: Option, + rate_limit_reset: Option, + }, #[error("Failed to read response: {0}")] ReadResponse(String), #[error("Failed to send request: {0}")] @@ -169,18 +179,27 @@ impl WebToolNetworkProvider { let response = client .get(url) + .header(reqwest::header::ACCEPT, WEB_FETCH_ACCEPT_VALUE) + .header( + reqwest::header::ACCEPT_LANGUAGE, + WEB_FETCH_ACCEPT_LANGUAGE_VALUE, + ) .send() .await .map_err(|error| WebToolNetworkError::Fetch(error.to_string()))?; if !response.status().is_success() { + let status = response.status(); return Err(WebToolNetworkError::HttpStatus { - status: response.status().to_string(), - reason: response - .status() + status_code: status.as_u16(), + status: status.to_string(), + reason: status .canonical_reason() .unwrap_or("Unknown error") .to_string(), + retry_after: response_header(&response, reqwest::header::RETRY_AFTER), + rate_limit_remaining: response_header_name(&response, "x-ratelimit-remaining"), + rate_limit_reset: response_header_name(&response, "x-ratelimit-reset"), }); } @@ -954,3 +973,22 @@ mod tests { assert_eq!(error.kind, WebSearchErrorKind::InvalidResponse); } } + +fn response_header( + response: &reqwest::Response, + name: reqwest::header::HeaderName, +) -> Option { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) +} + +fn response_header_name(response: &reqwest::Response, name: &str) -> Option { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) +} diff --git a/src/crates/services/services-integrations/tests/miniapp_loopx_contracts.rs b/src/crates/services/services-integrations/tests/miniapp_loopx_contracts.rs new file mode 100644 index 0000000000..164475e14d --- /dev/null +++ b/src/crates/services/services-integrations/tests/miniapp_loopx_contracts.rs @@ -0,0 +1,1846 @@ +use async_trait::async_trait; +use openbitfun_product_domains::miniapp::loopx::{ + LoopxAgentTurnStatus, LoopxCliBuildTurnRequest, LoopxCliCallContext, LoopxCliCreateGoalRequest, + LoopxCliErrorKind, LoopxCliGoalContext, LoopxCliHandshakeRequest, LoopxCliInspectGoalRequest, + LoopxCliInstallManagedSourceRequest, LoopxCliIntakePlan, LoopxCliPlanItemRequest, LoopxCliPort, + LoopxCliProgress, LoopxCliProgressSink, LoopxCliRunDecision, LoopxCliSettleTurnRequest, + LoopxCliSettlementStatus, LoopxCliSource, LoopxCliTodoPlan, LoopxIssueKey, LoopxItemKind, + LoopxPermissionScope, LoopxRemoteItemState, LoopxRepositoryKey, LoopxWorkspaceDisposeRequest, + LoopxWorkspacePort, LoopxWorkspacePrepareRequest, LoopxWorkspaceProbeRequest, + LoopxWorkspaceResetRequest, +}; +use openbitfun_services_integrations::miniapp::loopx_cli::{ + LoopxCliAdapterConfig, LoopxCliProcessAdapter, LoopxCommandPlan, LoopxCommandSource, + LoopxFixedCommandLocator, LoopxProcessError, LoopxProcessObserver, LoopxProcessOutput, + LoopxProcessProgress, LoopxProcessRunner, LoopxProgressStage, LoopxPythonLocator, + LoopxSystemFallbackPolicy, NoopLoopxProcessObserver, SystemLoopxProcessRunner, + LOOPX_COMMAND_REFERENCE_SCHEMA, LOOPX_PINNED_SOURCE_COMMIT, LOOPX_SOURCE_REPOSITORY, +}; +use openbitfun_services_integrations::miniapp::loopx_workspace::{ + canonical_github_remote, plan_git_clone_command, plan_workspace_layout, LoopxWorkspaceService, + LoopxWorkspaceServiceConfig, +}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, VecDeque}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio_util::sync::CancellationToken; + +#[derive(Default)] +struct RecordingProgressSink(Mutex>); + +impl LoopxCliProgressSink for RecordingProgressSink { + fn report(&self, progress: LoopxCliProgress) { + self.0.lock().unwrap().push(progress); + } +} + +#[derive(Default)] +struct RecordingProcessObserver(Mutex>); + +impl LoopxProcessObserver for RecordingProcessObserver { + fn on_progress(&self, progress: LoopxProcessProgress) { + self.0.lock().unwrap().push(progress); + } +} + +#[derive(Default)] +struct FakeRunner { + results: Mutex>>, + plans: Mutex>, +} + +impl FakeRunner { + fn with_results( + results: impl IntoIterator>, + ) -> Self { + Self { + results: Mutex::new(results.into_iter().collect()), + plans: Mutex::new(Vec::new()), + } + } + + fn plans(&self) -> Vec { + self.plans.lock().unwrap().clone() + } +} + +#[async_trait] +impl LoopxProcessRunner for FakeRunner { + async fn run( + &self, + plan: LoopxCommandPlan, + _cancellation: CancellationToken, + _observer: &dyn LoopxProcessObserver, + ) -> Result { + self.plans.lock().unwrap().push(plan); + self.results + .lock() + .unwrap() + .pop_front() + .expect("fake process result") + } +} + +#[derive(Default)] +struct ManagedInstallFakeRunner { + plans: Mutex>, +} + +#[async_trait] +impl LoopxProcessRunner for ManagedInstallFakeRunner { + async fn run( + &self, + plan: LoopxCommandPlan, + _cancellation: CancellationToken, + _observer: &dyn LoopxProcessObserver, + ) -> Result { + let is_git = plan.executable.file_stem().and_then(|value| value.to_str()) == Some("git"); + let stdout = if is_git && plan.args.first() == Some(&OsString::from("clone")) { + let target = PathBuf::from(plan.args.last().expect("clone target")); + std::fs::create_dir_all(target.join(".git")).unwrap(); + std::fs::create_dir_all(target.join("loopx")).unwrap(); + std::fs::write(target.join(".git").join("HEAD"), LOOPX_PINNED_SOURCE_COMMIT).unwrap(); + for file in [ + "pyproject.toml", + "LICENSE", + "NOTICE", + "LICENSE-MIT", + "TRADEMARKS.md", + ] { + std::fs::write(target.join(file), "fixture\n").unwrap(); + } + std::fs::write( + target.join("loopx").join("entrypoint.py"), + "def main(): pass\n", + ) + .unwrap(); + String::new() + } else if is_git && plan.args.last() == Some(&OsString::from("HEAD")) { + format!("{LOOPX_PINNED_SOURCE_COMMIT}\n") + } else if plan.args == [OsString::from("--version")] { + "Python 3.12.8\n".to_string() + } else if plan.args.last() == Some(&OsString::from("--version")) { + "loopx 0.5.1\n".to_string() + } else if plan.args.last() == Some(&OsString::from("commands")) { + json!({"ok": true, "schema_version": LOOPX_COMMAND_REFERENCE_SCHEMA}).to_string() + } else { + String::new() + }; + self.plans.lock().unwrap().push(plan); + Ok(LoopxProcessOutput { + stdout, + stderr_tail: Vec::new(), + elapsed: Duration::from_millis(1), + }) + } +} + +#[derive(Default)] +struct WorkspaceFakeRunner { + plans: Mutex>, + remote: Mutex, + /// Directory paths of registered linked worktrees, used by `worktree list`. + worktrees: Mutex>, +} + +impl WorkspaceFakeRunner { + fn new(remote: &str) -> Self { + Self { + plans: Mutex::new(Vec::new()), + remote: Mutex::new(remote.to_string()), + worktrees: Mutex::new(Vec::new()), + } + } +} + +#[async_trait] +impl LoopxProcessRunner for WorkspaceFakeRunner { + async fn run( + &self, + plan: LoopxCommandPlan, + _cancellation: CancellationToken, + _observer: &dyn LoopxProcessObserver, + ) -> Result { + if plan.args.first() == Some(&OsString::from("clone")) { + // Shared layout: `git clone --bare `. + let target = PathBuf::from(plan.args.last().expect("clone target")); + std::fs::create_dir_all(target.join("objects")).unwrap(); + std::fs::create_dir_all(target.join("refs")).unwrap(); + } else if plan.args.windows(3).any(|w| { + w == [ + OsString::from("worktree"), + OsString::from("add"), + OsString::from("-b"), + ] + }) { + // `git -C worktree add -b `. + let worktree = PathBuf::from(plan.args.last().expect("worktree add path")); + let bare = PathBuf::from(&plan.args[1]); + std::fs::create_dir_all(&worktree).unwrap(); + // Real git puts a `gitdir: ` pointer file in the linked + // worktree; simulate that so the ownership marker lands in the + // bare repo's per-worktree gitdir. + let name = worktree + .file_name() + .expect("worktree name") + .to_string_lossy(); + let gitdir = bare.join("worktrees").join(name.as_ref()); + std::fs::create_dir_all(&gitdir).unwrap(); + std::fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", gitdir.to_string_lossy()), + ) + .unwrap(); + std::fs::write(gitdir.join("bfx-linked"), b"ok").unwrap(); + self.worktrees.lock().unwrap().push(worktree.clone()); + } else if plan.args.windows(3).any(|w| { + w == [ + OsString::from("worktree"), + OsString::from("remove"), + OsString::from("--force"), + ] + }) { + let worktree = PathBuf::from(plan.args.last().expect("worktree remove path")); + let _ = std::fs::remove_dir_all(&worktree); + self.worktrees + .lock() + .unwrap() + .retain(|path| path != &worktree); + } else if plan + .args + .windows(2) + .any(|w| w == [OsString::from("worktree"), OsString::from("list")]) + { + // Porcelain output: first entry is the bare repository itself. + let mut stdout = format!( + "worktree {}\nHEAD 0000000000000000000000000000000000000000\n\n", + plan.args[1].to_string_lossy() + ); + for worktree in self.worktrees.lock().unwrap().iter() { + stdout.push_str(&format!( + "worktree {}\nHEAD 1111111111111111111111111111111111111111\n\n", + worktree.to_string_lossy() + )); + } + self.plans.lock().unwrap().push(plan); + return Ok(LoopxProcessOutput { + stdout, + stderr_tail: Vec::new(), + elapsed: Duration::from_millis(1), + }); + } + let stdout = if plan.args == [OsString::from("--version")] { + "git version 2.53.0\n".to_string() + } else if plan + .args + .windows(3) + .any(|args| args == ["config", "--get", "remote.origin.url"]) + { + format!("{}\n", self.remote.lock().unwrap()) + } else { + String::new() + }; + self.plans.lock().unwrap().push(plan); + Ok(LoopxProcessOutput { + stdout, + stderr_tail: Vec::new(), + elapsed: Duration::from_millis(1), + }) + } +} + +struct FakeLocator { + path: Option, + calls: AtomicUsize, +} + +impl FakeLocator { + fn new(path: Option) -> Self { + Self { + path, + calls: AtomicUsize::new(0), + } + } +} + +impl LoopxFixedCommandLocator for FakeLocator { + fn locate(&self) -> Result, String> { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(self.path.clone()) + } +} + +struct FakePythonLocator(PathBuf); + +impl LoopxPythonLocator for FakePythonLocator { + fn locate(&self) -> Result, String> { + Ok(Some(self.0.clone())) + } +} + +fn output(stdout: impl Into) -> Result { + Ok(LoopxProcessOutput { + stdout: stdout.into(), + stderr_tail: Vec::new(), + elapsed: Duration::from_millis(1), + }) +} + +fn stage_bundle(root: &Path, version: &str, schema: u32) -> PathBuf { + let bundle = root.join("loopx"); + std::fs::create_dir_all(&bundle).unwrap(); + let executable = bundle.join(if cfg!(windows) { "loopx.exe" } else { "loopx" }); + let bytes = b"test-only-loopx-binary"; + std::fs::write(&executable, bytes).unwrap(); + let digest = hex::encode(Sha256::digest(bytes)); + std::fs::write( + bundle.join("manifest.json"), + serde_json::to_vec_pretty(&json!({ + "schema_version": schema, + "name": "loopx", + "version": version, + "sha256": format!("sha256:{digest}"), + })) + .unwrap(), + ) + .unwrap(); + executable +} + +fn stage_managed_source(root: &Path) -> PathBuf { + let source = root.join("managed-source"); + std::fs::create_dir_all(source.join(".git")).unwrap(); + std::fs::create_dir_all(source.join("loopx")).unwrap(); + std::fs::write(source.join(".git").join("HEAD"), LOOPX_PINNED_SOURCE_COMMIT).unwrap(); + std::fs::write( + source.join("pyproject.toml"), + "[project]\nversion = \"0.5.1\"\n", + ) + .unwrap(); + std::fs::write( + source.join("loopx").join("entrypoint.py"), + "def main(): pass\n", + ) + .unwrap(); + std::fs::write( + source.join(".git").join(".bitfun-managed-source.json"), + serde_json::to_vec_pretty(&json!({ + "schema_version": 1, + "source_repository": LOOPX_SOURCE_REPOSITORY, + "source_tag": "v0.5.1", + "source_commit": LOOPX_PINNED_SOURCE_COMMIT, + "loopx_version": "0.5.1" + })) + .unwrap(), + ) + .unwrap(); + source +} + +fn handshake_results( + version: &str, + schema: &str, +) -> Vec> { + vec![ + output(format!("{version}\n")), + output(json!({"ok": true, "schema_version": schema}).to_string()), + ] +} + +fn adapter_with_runner( + resource_dir: &Path, + runner: Arc, + locator: Arc, +) -> LoopxCliProcessAdapter { + let mut config = LoopxCliAdapterConfig::packaged(resource_dir); + config.system_fallback = LoopxSystemFallbackPolicy::ExactPinned; + config.startup_deadline = Duration::from_secs(3); + config.command_deadline = Duration::from_secs(9); + LoopxCliProcessAdapter::with_dependencies( + config, + runner, + locator, + Arc::new(FakePythonLocator(PathBuf::from("python"))), + Arc::new(NoopLoopxProcessObserver), + ) +} + +fn handshake_request(operation_id: &str) -> LoopxCliHandshakeRequest { + LoopxCliHandshakeRequest { + call: LoopxCliCallContext { + operation_id: operation_id.to_string(), + deadline_at: None, + }, + ..LoopxCliHandshakeRequest::default() + } +} + +#[test] +fn packaged_startup_budget_covers_measured_windows_onefile_cold_start() { + let config = LoopxCliAdapterConfig::packaged(PathBuf::from("resources")); + assert!(config.startup_deadline >= Duration::from_secs(60)); + assert_eq!(config.command_deadline, Duration::from_secs(180)); +} + +#[tokio::test] +async fn packaged_bundle_is_preferred_and_exactly_handshaken() { + let temporary = tempfile::tempdir().unwrap(); + let bundled = stage_bundle(temporary.path(), "v0.5.1", 1); + let runner = Arc::new(FakeRunner::with_results(handshake_results( + "loopx 0.5.1", + LOOPX_COMMAND_REFERENCE_SCHEMA, + ))); + let locator = Arc::new(FakeLocator::new(Some(PathBuf::from("system-loopx")))); + let adapter = adapter_with_runner(temporary.path(), runner.clone(), locator.clone()); + + let manifest = adapter + .handshake( + handshake_request("handshake-bundle"), + &RecordingProgressSink::default(), + ) + .await + .unwrap(); + + assert_eq!(manifest.executable.source, LoopxCliSource::Bundled); + assert_eq!(manifest.loopx_version, "0.5.1"); + assert_eq!(manifest.schema_version, 1); + assert_eq!( + manifest.executable.path.as_deref(), + Some(bundled.to_string_lossy().as_ref()) + ); + assert!(manifest + .executable + .sha256 + .as_deref() + .unwrap() + .starts_with("sha256:")); + assert_eq!(locator.calls.load(Ordering::SeqCst), 0); + let plans = runner.plans(); + assert_eq!(plans.len(), 2); + assert_eq!(plans[0].executable, bundled); + assert_eq!(plans[0].args, vec![OsString::from("--version")]); + assert_eq!( + plans[1].args, + ["--format", "json", "commands"] + .into_iter() + .map(OsString::from) + .collect::>() + ); +} + +#[tokio::test] +async fn managed_github_source_is_preferred_before_the_system_fallback() { + let temporary = tempfile::tempdir().unwrap(); + let source = stage_managed_source(temporary.path()); + // The managed-source candidate runs the pristine-source probe (`git + // status --porcelain`) before the version handshake, so the first result + // is the probe's clean empty stdout. + let runner = Arc::new(FakeRunner::with_results([output("")].into_iter().chain( + handshake_results("loopx 0.5.1", LOOPX_COMMAND_REFERENCE_SCHEMA), + ))); + let system_locator = Arc::new(FakeLocator::new(Some(PathBuf::from("old-system-loopx")))); + let mut config = LoopxCliAdapterConfig::packaged(temporary.path().join("missing-resources")) + .with_managed_source_dir(&source); + config.system_fallback = LoopxSystemFallbackPolicy::ExactPinned; + let adapter = LoopxCliProcessAdapter::with_dependencies( + config, + runner.clone(), + system_locator.clone(), + Arc::new(FakePythonLocator(PathBuf::from("python"))), + Arc::new(NoopLoopxProcessObserver), + ); + + let manifest = adapter + .handshake( + handshake_request("handshake-managed-source"), + &RecordingProgressSink::default(), + ) + .await + .unwrap(); + + assert_eq!(manifest.executable.source, LoopxCliSource::PythonFallback); + assert_eq!(system_locator.calls.load(Ordering::SeqCst), 0); + let plans = runner.plans(); + let version = plans + .iter() + .find(|plan| { + plan.executable == PathBuf::from("python") + && plan.args.last() == Some(&OsString::from("--version")) + }) + .expect("managed source version handshake"); + assert_eq!(version.args[0], OsString::from("-I")); + assert_eq!(version.args[1], OsString::from("-c")); + assert_eq!(version.args[3], OsString::from("--version")); + assert_eq!( + version + .environment + .get(&OsString::from("BITFUN_LOOPX_SOURCE")), + Some(&source.as_os_str().to_owned()) + ); +} + +#[tokio::test] +async fn managed_source_install_clones_the_pinned_github_revision_and_activates_it() { + let temporary = tempfile::tempdir().unwrap(); + let target = temporary.path().join("runtime").join("loopx-source-v0.5.1"); + let runner = Arc::new(ManagedInstallFakeRunner::default()); + let config = LoopxCliAdapterConfig::packaged(temporary.path().join("missing-resources")) + .with_managed_source_dir(&target); + let adapter = LoopxCliProcessAdapter::with_dependencies( + config, + runner.clone(), + Arc::new(FakeLocator::new(None)), + Arc::new(FakePythonLocator(PathBuf::from("python"))), + Arc::new(NoopLoopxProcessObserver), + ); + + let installed = adapter + .install_managed_source( + LoopxCliInstallManagedSourceRequest { + call: LoopxCliCallContext { + operation_id: "install-managed-source".to_string(), + deadline_at: None, + }, + }, + &RecordingProgressSink::default(), + ) + .await + .unwrap(); + + assert_eq!(installed.source_repository, LOOPX_SOURCE_REPOSITORY); + assert_eq!(installed.source_commit, LOOPX_PINNED_SOURCE_COMMIT); + assert_eq!(installed.install_path, target.to_string_lossy().as_ref()); + assert!(target + .join(".git") + .join(".bitfun-managed-source.json") + .is_file()); + let plans = runner.plans.lock().unwrap(); + let clone = plans + .iter() + .find(|plan| { + plan.executable.file_stem().and_then(|value| value.to_str()) == Some("git") + && plan.args[0] == OsString::from("clone") + }) + .expect("git clone plan"); + assert!(clone + .args + .contains(&OsString::from(LOOPX_SOURCE_REPOSITORY))); + assert!(clone.args.contains(&OsString::from("v0.5.1"))); + assert!(clone.args.contains(&OsString::from("--filter=blob:none"))); + assert!(clone.args.contains(&OsString::from("--sparse"))); + assert!(plans.iter().any(|plan| { + plan.args.windows(3).any(|args| { + args == [ + OsString::from("sparse-checkout"), + OsString::from("set"), + OsString::from("--no-cone"), + ] + }) + })); + assert!(plans.iter().any(|plan| { + plan.args.contains(&OsString::from("/loopx/")) + && plan.args.contains(&OsString::from("/pyproject.toml")) + && plan.args.contains(&OsString::from("/LICENSE")) + })); +} + +#[tokio::test] +async fn runtime_version_mismatch_is_a_non_retryable_typed_error() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 1); + let runner = Arc::new(FakeRunner::with_results([output("loopx 0.2.12\n")])); + let adapter = adapter_with_runner(temporary.path(), runner, Arc::new(FakeLocator::new(None))); + + let error = adapter + .handshake( + handshake_request("handshake-version"), + &RecordingProgressSink::default(), + ) + .await + .unwrap_err(); + + assert_eq!(error.kind, LoopxCliErrorKind::VersionMismatch); + assert!(!error.retryable); +} + +#[tokio::test] +async fn command_reference_schema_mismatch_is_rejected() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 1); + let runner = Arc::new(FakeRunner::with_results(handshake_results( + "loopx 0.5.1", + "future_schema_v99", + ))); + let adapter = adapter_with_runner(temporary.path(), runner, Arc::new(FakeLocator::new(None))); + + let error = adapter + .handshake( + handshake_request("handshake-schema"), + &RecordingProgressSink::default(), + ) + .await + .unwrap_err(); + + assert_eq!(error.kind, LoopxCliErrorKind::SchemaMismatch); +} + +#[tokio::test] +async fn item_plan_uses_structured_registry_and_worktree_arguments() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 1); + let worktree = temporary.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let registry = worktree.join(".loopx").join("registry.json"); + let workflow = json!({ + "ok": true, + "schema_version": "issue_fix_workflow_plan_packet_v0", + "ordered_loopx_todo_writeback_preview": [{ + "role": "agent", + "task_class": "advancement_task", + "action_kind": "fix_issue", + "text": "[P1] Fix issue #42" + }] + }); + let runner = Arc::new(FakeRunner::with_results( + handshake_results("loopx 0.5.1", LOOPX_COMMAND_REFERENCE_SCHEMA) + .into_iter() + .chain([output(workflow.to_string())]), + )); + let adapter = adapter_with_runner( + temporary.path(), + runner.clone(), + Arc::new(FakeLocator::new(None)), + ); + let item = LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }; + let request = LoopxCliPlanItemRequest { + context: LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: "plan-item".to_string(), + deadline_at: None, + }, + task_id: "task-42".to_string(), + generation: 1, + worktree_path: worktree.to_string_lossy().into_owned(), + registry_path: registry.to_string_lossy().into_owned(), + available_capabilities: vec!["shell".to_string()], + }, + item, + title: "Issue with “UTF-8” title".to_string(), + state: LoopxRemoteItemState::Open, + labels: vec!["bug".to_string()], + }; + + let plan = adapter + .plan_item(request, &RecordingProgressSink::default()) + .await + .unwrap(); + + // The goal objective now comes from the host-resolved title; the packet + // itself carries no objective field. + assert_eq!(plan.objective, "Fix #42: Issue with “UTF-8” title"); + assert_eq!(plan.todos.len(), 1); + let command = runner.plans().pop().unwrap(); + assert_eq!(command.current_dir.as_deref(), Some(worktree.as_path())); + assert!(command.environment.is_empty()); + assert_eq!(command.deadline, Duration::from_secs(9)); + assert_eq!( + command.args, + vec![ + OsString::from("--format"), + OsString::from("json"), + OsString::from("--registry"), + registry.as_os_str().to_owned(), + OsString::from("issue-fix"), + OsString::from("workflow-plan"), + OsString::from("--url"), + OsString::from("https://github.com/owner/repo/issues/42"), + OsString::from("--repo-path"), + worktree.as_os_str().to_owned(), + OsString::from("--metadata-json"), + OsString::from( + json!({ + "number": 42, + "state": "open", + "title": "Issue with “UTF-8” title", + "labels": ["bug"], + "kind": "issue", + }) + .to_string(), + ), + ] + ); +} + +#[tokio::test] +async fn item_plan_process_failure_preserves_the_stderr_cause() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 1); + let worktree = temporary.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let registry = worktree.join(".loopx").join("registry.json"); + let runner = Arc::new(FakeRunner::with_results( + handshake_results("loopx 0.5.1", LOOPX_COMMAND_REFERENCE_SCHEMA) + .into_iter() + .chain([Err(LoopxProcessError::Exited { + code: Some(1), + stdout_tail: vec![ + "irrelevant output before the error".repeat(30), + r#"{"ok":false,"error":"metadata projection failed"}"#.to_string(), + ], + stderr_tail: Vec::new(), + payload: None, + })]), + )); + let adapter = adapter_with_runner(temporary.path(), runner, Arc::new(FakeLocator::new(None))); + let error = adapter + .plan_item( + LoopxCliPlanItemRequest { + context: LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: "plan-item-failure".to_string(), + deadline_at: None, + }, + task_id: "task-42".to_string(), + generation: 1, + worktree_path: worktree.to_string_lossy().into_owned(), + registry_path: registry.to_string_lossy().into_owned(), + available_capabilities: vec!["shell".to_string()], + }, + item: LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }, + title: "Issue with UTF-8 title".to_string(), + state: LoopxRemoteItemState::Unknown, + labels: Vec::new(), + }, + &RecordingProgressSink::default(), + ) + .await + .unwrap_err(); + + assert_eq!(error.kind, LoopxCliErrorKind::Process); + assert!(error.message.contains("metadata projection failed")); + assert!(error.message.contains("status Some(1)")); +} + +#[tokio::test] +async fn waiting_goal_projects_the_concrete_open_user_gate() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 1); + let worktree = temporary.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let registry = worktree.join(".loopx").join("registry.json"); + let turn_plan = json!({ + "ok": true, + "status": "operator_gate_notify", + "schema_version": "loopx_turn_plan_v0", + "turn_envelope": { + "should_run": true, + "state": "active", + "effective_action": "operator_gate_notify", + "open_count": 1, + "user": { + "action_required": true, + "open_count": 1 + }, + "action_signature": { + "source_decision_hash": "sha256:user-gate-revision" + } + } + }); + let todos = json!({ + "ok": true, + "todos": [{ + "todo_id": "todo_release_approval", + "role": "user", + "task_class": "user_gate", + "status": "open", + "done": false, + "text": "Approve creating the draft pull request", + "action_kind": "gate" + }] + }); + let runner = Arc::new(FakeRunner::with_results( + handshake_results("loopx 0.5.1", LOOPX_COMMAND_REFERENCE_SCHEMA) + .into_iter() + .chain([output(turn_plan.to_string()), output(todos.to_string())]), + )); + let adapter = adapter_with_runner( + temporary.path(), + runner.clone(), + Arc::new(FakeLocator::new(None)), + ); + + let snapshot = adapter + .inspect_goal( + LoopxCliInspectGoalRequest { + context: LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: "inspect-user-gate".to_string(), + deadline_at: None, + }, + task_id: "task-42".to_string(), + generation: 3, + worktree_path: worktree.to_string_lossy().into_owned(), + registry_path: registry.to_string_lossy().into_owned(), + available_capabilities: [ + "filesystem_read", + "filesystem_write", + "shell", + "network", + "external_evidence_poll", + ] + .into_iter() + .map(str::to_string) + .collect(), + }, + goal_id: "goal-42".to_string(), + agent_id: "bitfun-agent".to_string(), + }, + &RecordingProgressSink::default(), + ) + .await + .unwrap(); + + // Since the custom-runner alignment (9fa63eb8c), `should_run=true` wins + // over a pending user gate: the controller keeps driving independent + // safe work while projecting the gate concurrently, so the snapshot is + // `RunNow` with the concrete open user gate still attached. + assert_eq!(snapshot.run_decision, LoopxCliRunDecision::RunNow); + assert_eq!(snapshot.waiting_user_todo_count, 1); + let gate = snapshot.pending_user_gate.expect("projected user gate"); + assert_eq!(gate.gate_id, "todo_release_approval"); + assert_eq!(gate.message, "Approve creating the draft pull request"); + assert_eq!(gate.action_kind.as_deref(), Some("gate")); + let commands = runner + .plans() + .into_iter() + .skip(2) + .map(|plan| plan.args) + .collect::>(); + assert_eq!(commands.len(), 2); + for capability in ["filesystem_read", "filesystem_write", "shell", "network"] { + assert!(commands[0].windows(2).any(|args| { + args == [ + OsString::from("--available-capability"), + OsString::from(capability), + ] + })); + } + assert!(commands[1] + .windows(2) + .any(|args| { args == [OsString::from("todo"), OsString::from("list")] })); +} + +/// The pinned CLI answers `turn plan` for its plan-exhausted replan frontier +/// (all todos done or blocked, open replan obligation, no selected todo) with +/// `ok:false`, the `host-bound routes require ... lineage` error, and exit 1. +/// `inspect_goal` must salvage the typed payload into the equivalent +/// read-only `RunNow`-without-todo snapshot so the controller parks the task +/// instead of failing it with a raw process error (observed live on task +/// anywhere-labs/dsh-desktop#827). +#[tokio::test] +async fn inspect_goal_salvages_the_replan_lineage_contract_error() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 1); + let worktree = temporary.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let registry = worktree.join(".loopx").join("registry.json"); + let turn_plan = json!({ + "ok": false, + "schema_version": "loopx_turn_plan_v0", + "error": "host-bound routes require goal, agent, todo, and action-hash lineage", + "route": {"kind": "contract_error"}, + "turn_envelope": { + "should_run": true, + "state": "active", + "effective_action": "autonomous_replan_required", + "open_count": 0, + "user": {"action_required": false, "open_count": 0}, + "action": {"selected_todo": null}, + "replan_action_packet": {"obligation_id": "replan-1"}, + "compaction": {"within_budget": true}, + "action_signature": { + "source_decision_hash": "sha256:replan-lineage-revision" + } + } + }); + let runner = Arc::new(FakeRunner::with_results( + handshake_results("loopx 0.5.1", LOOPX_COMMAND_REFERENCE_SCHEMA) + .into_iter() + .chain([Err(LoopxProcessError::Exited { + code: Some(1), + stdout_tail: Vec::new(), + stderr_tail: Vec::new(), + payload: Some(turn_plan), + })]), + )); + let adapter = adapter_with_runner( + temporary.path(), + runner.clone(), + Arc::new(FakeLocator::new(None)), + ); + + let snapshot = adapter + .inspect_goal( + LoopxCliInspectGoalRequest { + context: LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: "inspect-replan-lineage".to_string(), + deadline_at: None, + }, + task_id: "task-827".to_string(), + generation: 6, + worktree_path: worktree.to_string_lossy().into_owned(), + registry_path: registry.to_string_lossy().into_owned(), + available_capabilities: vec!["shell".to_string()], + }, + goal_id: "goal-827".to_string(), + agent_id: "bitfun-agent".to_string(), + }, + &RecordingProgressSink::default(), + ) + .await + .unwrap(); + + assert_eq!(snapshot.run_decision, LoopxCliRunDecision::RunNow); + assert_eq!(snapshot.open_todo_count, 0); + assert_eq!(snapshot.waiting_user_todo_count, 0); + assert_eq!(snapshot.selected_todo, None); + assert_eq!(snapshot.durable_revision, "sha256:replan-lineage-revision"); + // The open obligation must ride along so the controller drives the + // replan turn instead of parking the task. + assert_eq!( + snapshot.pending_replan_obligation_id.as_deref(), + Some("replan-1") + ); + assert!(!snapshot.envelope_over_budget); + // The salvaged projection performs no follow-up todo list command. + assert_eq!(runner.plans().len(), 3); +} + +/// A turn that completed its writeback and quota spend must still settle when +/// the CLI answers the settlement inspection with the replan-lineage contract +/// error: durable evidence comes from `history`, and the salvaged snapshot +/// feeds `after_revision` (observed live: task #827's final onboarding turn +/// failed settlement purely because of this inspection error). +#[tokio::test] +async fn settle_turn_survives_the_replan_lineage_contract_error() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 1); + let worktree = temporary.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let registry = worktree.join(".loopx").join("registry.json"); + let turn_plan = json!({ + "ok": false, + "schema_version": "loopx_turn_plan_v0", + "error": "host-bound routes require goal, agent, todo, and action-hash lineage", + "turn_envelope": { + "should_run": true, + "state": "active", + "effective_action": "autonomous_replan_required", + "open_count": 0, + "user": {"action_required": false, "open_count": 0}, + "action": {"selected_todo": null}, + "replan_action_packet": {"obligation_id": "replan-1"}, + "compaction": {"within_budget": true}, + "action_signature": { + "source_decision_hash": "sha256:replan-lineage-revision" + } + } + }); + let effect_id = "goal-827:bitfun-agent:todo-1:turn-827"; + let history = json!({ + "ok": true, + "goals": [{ + "id": "goal-827", + "latest_runs": [ + { + "goal_id": "goal-827", + "agent_id": "bitfun-agent", + "todo_id": "todo-1", + "turn_instance_id": "turn-827", + "classification": "quota_slot_spent", + "settlement_identity": { + "schema_version": "quota_settlement_identity_v0", + "effect_id": effect_id, + "goal_id": "goal-827", + "agent_id": "bitfun-agent", + "todo_id": "todo-1", + "turn_instance_id": "turn-827" + } + }, + { + "goal_id": "goal-827", + "agent_id": "bitfun-agent", + "todo_id": "todo-1", + "turn_instance_id": "turn-827", + "classification": "validated_progress", + "delivery_outcome": "outcome_progress", + "settlement_identity": { + "schema_version": "quota_settlement_identity_v0", + "effect_id": effect_id, + "goal_id": "goal-827", + "agent_id": "bitfun-agent", + "todo_id": "todo-1", + "turn_instance_id": "turn-827" + } + } + ] + }] + }); + let runner = Arc::new(FakeRunner::with_results( + handshake_results("loopx 0.5.1", LOOPX_COMMAND_REFERENCE_SCHEMA) + .into_iter() + .chain([ + Err(LoopxProcessError::Exited { + code: Some(1), + stdout_tail: Vec::new(), + stderr_tail: Vec::new(), + payload: Some(turn_plan), + }), + output(history.to_string()), + ]), + )); + let adapter = adapter_with_runner( + temporary.path(), + runner.clone(), + Arc::new(FakeLocator::new(None)), + ); + + let settlement = adapter + .verify_turn_settlement( + LoopxCliSettleTurnRequest { + context: LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: "settle-replan-lineage".to_string(), + deadline_at: None, + }, + task_id: "task-827".to_string(), + generation: 6, + worktree_path: worktree.to_string_lossy().into_owned(), + registry_path: registry.to_string_lossy().into_owned(), + available_capabilities: vec![], + }, + goal_id: "goal-827".to_string(), + agent_id: "bitfun-agent".to_string(), + turn_id: "turn-827".to_string(), + settlement_token: effect_id.to_string(), + expected_durable_revision: "sha256:replan-lineage-revision".to_string(), + agent_status: LoopxAgentTurnStatus::Completed, + }, + &RecordingProgressSink::default(), + ) + .await + .unwrap(); + + assert_eq!(settlement.status, LoopxCliSettlementStatus::Settled); + assert_eq!(settlement.receipt_id, effect_id); + assert_eq!(settlement.after_revision, "sha256:replan-lineage-revision"); + // The settlement must still consult durable history evidence. + let commands = runner + .plans() + .into_iter() + .skip(2) + .map(|plan| plan.args) + .collect::>(); + assert!(commands.iter().any(|args| { + args.windows(2) + .any(|w| w == [OsString::from("history"), OsString::from("--goal-id")]) + })); +} + +/// Only the exact replan-lineage contract error is salvaged; other non-zero +/// exits keep their typed process error so unrelated CLI failures still +/// surface. +#[tokio::test] +async fn inspect_goal_keeps_other_process_failures() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 1); + let worktree = temporary.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let registry = worktree.join(".loopx").join("registry.json"); + let unrelated = json!({ + "ok": false, + "schema_version": "loopx_turn_plan_v0", + "error": "some other CLI failure", + "turn_envelope": { + "should_run": true, + "action": {"selected_todo": null}, + "replan_action_packet": {"obligation_id": "replan-1"} + } + }); + let runner = Arc::new(FakeRunner::with_results( + handshake_results("loopx 0.5.1", LOOPX_COMMAND_REFERENCE_SCHEMA) + .into_iter() + .chain([Err(LoopxProcessError::Exited { + code: Some(1), + stdout_tail: vec![r#" "error": "some other CLI failure""#.to_string()], + stderr_tail: Vec::new(), + payload: Some(unrelated), + })]), + )); + let adapter = adapter_with_runner(temporary.path(), runner, Arc::new(FakeLocator::new(None))); + + let error = adapter + .inspect_goal( + LoopxCliInspectGoalRequest { + context: LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: "inspect-unrelated-failure".to_string(), + deadline_at: None, + }, + task_id: "task-x".to_string(), + generation: 1, + worktree_path: worktree.to_string_lossy().into_owned(), + registry_path: registry.to_string_lossy().into_owned(), + available_capabilities: vec![], + }, + goal_id: "goal-x".to_string(), + agent_id: "bitfun-agent".to_string(), + }, + &RecordingProgressSink::default(), + ) + .await + .unwrap_err(); + + assert!(error.message.contains("some other CLI failure")); +} + +#[tokio::test] +async fn ordinary_monitor_wait_does_not_require_a_user_gate() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 1); + let worktree = temporary.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let registry = worktree.join(".loopx").join("registry.json"); + let turn_plan = json!({ + "ok": true, + "schema_version": "loopx_turn_plan_v0", + "turn_envelope": { + "should_run": false, + "state": "waiting", + "action_required": false, + "user": { + "action_required": false, + "open_count": 0 + }, + "action_signature": { + "source_decision_hash": "sha256:monitor-wait-revision" + } + } + }); + let runner = Arc::new(FakeRunner::with_results( + handshake_results("loopx 0.5.1", LOOPX_COMMAND_REFERENCE_SCHEMA) + .into_iter() + .chain([output(turn_plan.to_string())]), + )); + let adapter = adapter_with_runner( + temporary.path(), + runner.clone(), + Arc::new(FakeLocator::new(None)), + ); + + let snapshot = adapter + .inspect_goal( + LoopxCliInspectGoalRequest { + context: LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: "inspect-monitor-wait".to_string(), + deadline_at: None, + }, + task_id: "task-monitor".to_string(), + generation: 1, + worktree_path: worktree.to_string_lossy().into_owned(), + registry_path: registry.to_string_lossy().into_owned(), + available_capabilities: vec!["shell".to_string()], + }, + goal_id: "goal-monitor".to_string(), + agent_id: "bitfun-agent".to_string(), + }, + &RecordingProgressSink::default(), + ) + .await + .unwrap(); + + assert_eq!(snapshot.run_decision, LoopxCliRunDecision::Wait); + assert_eq!(snapshot.pending_user_gate, None); + assert_eq!(runner.plans().len(), 3); +} + +#[tokio::test] +async fn build_turn_accepts_a_fresh_guard_revision_as_the_agent_contract() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 1); + let worktree = temporary.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let registry = worktree.join(".loopx").join("registry.json"); + let envelope = json!({ + "ok": true, + "schema_version": "loopx_turn_envelope_v0", + "goal_id": "goal-42", + "agent_id": "bitfun-agent", + "should_run": true, + "state": "eligible", + "action": { + "recommended_action": "Fix the selected issue.", + "selected_todo": {"todo_id": "todo-42"} + }, + "required_reads": [], + "boundary": {"write_scope": "workspace"}, + "execution_policy": {"normal_delivery_allowed": true}, + "writeback": {"spend_after_validation": true}, + "contract_capsule": {"schema_version": "loopx_contract_capsule_v0"}, + "action_signature": {"source_hash": "sha256:durable-revision"} + }); + let runner = Arc::new(FakeRunner::with_results( + handshake_results("loopx 0.5.1", LOOPX_COMMAND_REFERENCE_SCHEMA) + .into_iter() + .chain([output(envelope.to_string())]), + )); + let adapter = adapter_with_runner( + temporary.path(), + runner.clone(), + Arc::new(FakeLocator::new(None)), + ); + + let turn = adapter + .build_turn( + LoopxCliBuildTurnRequest { + context: LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: "build-turn".to_string(), + deadline_at: None, + }, + task_id: "task-42".to_string(), + generation: 3, + worktree_path: worktree.to_string_lossy().into_owned(), + registry_path: registry.to_string_lossy().into_owned(), + available_capabilities: vec!["shell".to_string()], + }, + goal_id: "goal-42".to_string(), + agent_id: "bitfun-agent".to_string(), + expected_durable_revision: "sha256:earlier-inspect-revision".to_string(), + }, + &RecordingProgressSink::default(), + ) + .await + .unwrap(); + + assert!(turn.agent_instruction.contains("Fix the selected issue.")); + assert!(turn + .agent_instruction + .contains("Claim the selected executable todo")); + assert_eq!(turn.durable_revision, "sha256:durable-revision"); + assert_eq!( + turn.settlement_token, + format!("goal-42:bitfun-agent:todo-42:{}", turn.turn_id) + ); + let commands = runner + .plans() + .into_iter() + .skip(2) + .map(|plan| plan.args) + .collect::>(); + assert_eq!(commands.len(), 1); + assert!(commands[0] + .windows(2) + .any(|args| args == ["quota", "should-run"])); + assert!(commands[0].contains(&OsString::from("--turn-envelope"))); + assert!(commands[0].windows(2).any(|args| { + args == [ + OsString::from("--available-capability"), + OsString::from("shell"), + ] + })); + assert!(!commands[0].windows(2).any(|args| args == ["turn", "plan"])); +} + +#[tokio::test] +async fn create_goal_recovery_does_not_duplicate_an_existing_planned_todo() { + let temporary = tempfile::tempdir().unwrap(); + stage_bundle(temporary.path(), "v0.5.1", 1); + let worktree = temporary.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + let registry = worktree.join(".loopx").join("registry.json"); + let planned_todo = LoopxCliTodoPlan { + role: "agent".to_string(), + task_class: "advancement_task".to_string(), + action_kind: Some("fix_issue".to_string()), + text: "[P1] Fix issue #42".to_string(), + target_key: None, + }; + let results = handshake_results("loopx 0.5.1", LOOPX_COMMAND_REFERENCE_SCHEMA) + .into_iter() + .chain([ + output(json!({"ok": true, "state_action": "kept"}).to_string()), + output(json!({"ok": true}).to_string()), + output( + json!({ + "ok": true, + "todos": [{ + "role": planned_todo.role.clone(), + "task_class": planned_todo.task_class.clone(), + "action_kind": planned_todo.action_kind.clone(), + "text": planned_todo.text.clone(), + }] + }) + .to_string(), + ), + output( + json!({ + "ok": true, + "schema_version": "loopx_turn_plan_v0", + "turn_envelope": { + "action_signature": {"source_decision_hash": "sha256:durable-revision"} + }, + "transaction": {"turn_key": "sha256:turn"} + }) + .to_string(), + ), + ]); + let runner = Arc::new(FakeRunner::with_results(results)); + let adapter = adapter_with_runner( + temporary.path(), + runner.clone(), + Arc::new(FakeLocator::new(None)), + ); + let item = LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }; + let request = LoopxCliCreateGoalRequest { + context: LoopxCliGoalContext { + call: LoopxCliCallContext { + operation_id: "create-recovery".to_string(), + deadline_at: None, + }, + task_id: "task-42".to_string(), + generation: 2, + worktree_path: worktree.to_string_lossy().into_owned(), + registry_path: registry.to_string_lossy().into_owned(), + available_capabilities: vec!["shell".to_string()], + }, + goal_id: "goal-42".to_string(), + agent_id: "bitfun-agent".to_string(), + intake: LoopxCliIntakePlan { + item, + objective: "Fix issue 42".to_string(), + todos: vec![planned_todo], + }, + granted_scopes: vec![LoopxPermissionScope::WorkspaceWrite], + }; + + let result = adapter + .create_goal(request, &RecordingProgressSink::default()) + .await + .unwrap(); + + assert!(!result.created); + assert_eq!(result.durable_revision, "sha256:durable-revision"); + let commands = runner + .plans() + .into_iter() + .skip(2) + .map(|plan| plan.args) + .collect::>(); + assert_eq!(commands.len(), 4); + assert!(!commands.iter().any(|args| { + args.windows(2) + .any(|pair| pair[0] == OsString::from("todo") && pair[1] == OsString::from("add")) + })); +} + +#[test] +fn workspace_plan_uses_hashed_paths_and_noninteractive_structured_clone() { + let root = if cfg!(windows) { + PathBuf::from(r"C:\managed-loopx") + } else { + PathBuf::from("/managed-loopx") + }; + let item = LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "Owner".to_string(), + repository: "Repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }; + + let layout = plan_workspace_layout(&root, "task-sensitive-name", &item).unwrap(); + let command = plan_git_clone_command(Path::new("git"), &layout); + + assert!(layout.worktree_path.starts_with(&root)); + assert!(!layout.worktree_path.to_string_lossy().contains("Owner")); + assert!(layout + .registry_path + .ends_with(Path::new(".loopx/registry.json"))); + assert_eq!(command.args[0], OsString::from("clone")); + assert_eq!(command.args[1], OsString::from("--no-checkout")); + assert!(command.args.contains(&OsString::from("--"))); + assert_eq!( + command + .environment + .get(&OsString::from("GIT_TERMINAL_PROMPT")), + Some(&OsString::from("0")) + ); + assert_eq!( + canonical_github_remote("git@github.com:OWNER/Repo.git").as_deref(), + Some("github.com/owner/repo") + ); +} + +#[tokio::test] +async fn workspace_prepare_clones_once_then_reuses_verified_marker_and_origin() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("loopx-workspaces"); + let runner = Arc::new(WorkspaceFakeRunner::new( + "https://github.com/owner/repo.git", + )); + let mut config = LoopxWorkspaceServiceConfig::new(&root, "git"); + config.clone_deadline = Duration::from_secs(7); + config.command_deadline = Duration::from_secs(3); + let service = LoopxWorkspaceService::with_runner( + config, + runner.clone(), + Arc::new(NoopLoopxProcessObserver), + ); + let item = LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }; + let request = LoopxWorkspacePrepareRequest { + operation_id: "workspace-first".to_string(), + task_id: "task-42".to_string(), + item: item.clone(), + }; + + let created = service.prepare(request).await.unwrap(); + let reused = service + .prepare(LoopxWorkspacePrepareRequest { + operation_id: "workspace-second".to_string(), + task_id: "task-42".to_string(), + item, + }) + .await + .unwrap(); + + assert!(!created.reused); + assert!(created.repository_verified); + assert!(reused.reused); + assert_eq!(created.worktree_path, reused.worktree_path); + assert!(Path::new(&created.registry_path).ends_with(Path::new(".loopx/registry.json"))); + let plans = runner.plans.lock().unwrap(); + // Shared layout: bare clone + worktree add + origin verify, then the + // reuse path verifies the origin again. + assert_eq!(plans.len(), 4); + assert_eq!(plans[0].deadline, Duration::from_secs(7)); + assert_eq!(plans[1].deadline, Duration::from_secs(7)); + assert_eq!(plans[2].deadline, Duration::from_secs(3)); + assert_eq!(plans[3].deadline, Duration::from_secs(3)); + assert_eq!(plans[0].args[0], OsString::from("clone")); + assert!(plans[0].args.contains(&OsString::from("--bare"))); + assert!(plans[1].args.windows(3).any(|w| w + == [ + OsString::from("worktree"), + OsString::from("add"), + OsString::from("-b") + ])); +} + +#[tokio::test] +async fn workspace_dispose_removes_linked_worktree_and_last_shared_bare_repository() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("loopx-workspaces"); + let runner = Arc::new(WorkspaceFakeRunner::new( + "https://github.com/owner/repo.git", + )); + let service = LoopxWorkspaceService::with_runner( + LoopxWorkspaceServiceConfig::new(&root, "git"), + runner.clone(), + Arc::new(NoopLoopxProcessObserver), + ); + let item = LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }; + + let prepared = service + .prepare(LoopxWorkspacePrepareRequest { + operation_id: "workspace-dispose-prepare".to_string(), + task_id: "task-42".to_string(), + item: item.clone(), + }) + .await + .unwrap(); + + // A marker outside the worktree keeps dispose from touching it. + let worktree = PathBuf::from(&prepared.worktree_path); + assert!(worktree.exists()); + let bare = worktree.parent().expect("worktree parent").join("bare.git"); + assert!(bare.exists()); + + let disposed = service + .dispose(LoopxWorkspaceDisposeRequest { + operation_id: "workspace-dispose".to_string(), + task_id: "task-42".to_string(), + item, + }) + .await + .unwrap(); + assert!(disposed.removed); + assert!(!worktree.exists()); + // Last linked worktree was removed, so the bare repository is gone too. + assert!(!bare.exists()); + + let plans = runner.plans.lock().unwrap(); + assert!(plans.iter().any(|plan| { + plan.args.windows(3).any(|w| { + w == [ + OsString::from("worktree"), + OsString::from("remove"), + OsString::from("--force"), + ] + }) + })); + assert!(plans.iter().any(|plan| { + plan.args + .windows(2) + .any(|w| w == [OsString::from("worktree"), OsString::from("list")]) + })); +} + +#[tokio::test] +async fn workspace_reset_detaches_tasks_but_retains_bare_repository_cache() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("loopx-workspaces"); + let runner = Arc::new(WorkspaceFakeRunner::new( + "https://github.com/owner/repo.git", + )); + let service = LoopxWorkspaceService::with_runner( + LoopxWorkspaceServiceConfig::new(&root, "git"), + runner.clone(), + Arc::new(NoopLoopxProcessObserver), + ); + let item = LoopxIssueKey { + repository: LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }, + kind: LoopxItemKind::Issue, + number: 42, + }; + let prepared = service + .prepare(LoopxWorkspacePrepareRequest { + operation_id: "workspace-reset-prepare".to_string(), + task_id: "task-42".to_string(), + item, + }) + .await + .unwrap(); + let worktree = PathBuf::from(prepared.worktree_path); + let repository_dir = worktree.parent().unwrap().to_path_buf(); + let bare = repository_dir.join("bare.git"); + std::fs::write(worktree.join("large-generated-file"), b"payload").unwrap(); + + let reset = service + .reset(LoopxWorkspaceResetRequest { + operation_id: "workspace-reset".to_string(), + }) + .await + .unwrap(); + + assert!(reset.removed); + assert!(root.exists()); + assert!(bare.exists()); + assert!(!worktree.exists()); + assert!(runner.plans.lock().unwrap().iter().any(|plan| { + plan.args + .windows(2) + .any(|pair| pair == ["worktree", "prune"]) + })); +} + +#[tokio::test] +async fn workspace_dispose_keeps_shared_bare_repository_while_other_worktrees_exist() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("loopx-workspaces"); + let runner = Arc::new(WorkspaceFakeRunner::new( + "https://github.com/owner/repo.git", + )); + let service = LoopxWorkspaceService::with_runner( + LoopxWorkspaceServiceConfig::new(&root, "git"), + runner.clone(), + Arc::new(NoopLoopxProcessObserver), + ); + let repo = LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }; + let item_42 = LoopxIssueKey { + repository: repo.clone(), + kind: LoopxItemKind::Issue, + number: 42, + }; + let item_43 = LoopxIssueKey { + repository: repo, + kind: LoopxItemKind::Issue, + number: 43, + }; + + let first = service + .prepare(LoopxWorkspacePrepareRequest { + operation_id: "workspace-shared-prepare-1".to_string(), + task_id: "task-42".to_string(), + item: item_42.clone(), + }) + .await + .unwrap(); + let second = service + .prepare(LoopxWorkspacePrepareRequest { + operation_id: "workspace-shared-prepare-2".to_string(), + task_id: "task-43".to_string(), + item: item_43.clone(), + }) + .await + .unwrap(); + let bare = PathBuf::from(&first.worktree_path) + .parent() + .expect("worktree parent") + .join("bare.git"); + assert!(bare.exists()); + // Same repository shares one bare object database. + assert_eq!( + Path::new(&first.worktree_path) + .parent() + .expect("first parent"), + Path::new(&second.worktree_path) + .parent() + .expect("second parent") + ); + + // Removing only one worktree keeps the shared bare repository. + let disposed = service + .dispose(LoopxWorkspaceDisposeRequest { + operation_id: "workspace-shared-dispose-1".to_string(), + task_id: "task-42".to_string(), + item: item_42, + }) + .await + .unwrap(); + assert!(disposed.removed); + assert!(!Path::new(&first.worktree_path).exists()); + assert!(bare.exists()); + + // Removing the last worktree removes the bare repository as well. + let final_dispose = service + .dispose(LoopxWorkspaceDisposeRequest { + operation_id: "workspace-shared-dispose-2".to_string(), + task_id: "task-43".to_string(), + item: item_43, + }) + .await + .unwrap(); + assert!(final_dispose.removed); + assert!(!bare.exists()); +} + +#[tokio::test] +async fn workspace_probe_checks_git_root_writability_and_repository_access() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("loopx-workspaces"); + let runner = Arc::new(WorkspaceFakeRunner::new( + "https://github.com/owner/repo.git", + )); + let service = LoopxWorkspaceService::with_runner( + LoopxWorkspaceServiceConfig::new(&root, "git"), + runner.clone(), + Arc::new(NoopLoopxProcessObserver), + ); + + let result = service + .probe(LoopxWorkspaceProbeRequest { + operation_id: "workspace-probe".to_string(), + repository: Some(LoopxRepositoryKey { + host: "github.com".to_string(), + owner: "owner".to_string(), + repository: "repo".to_string(), + }), + }) + .await + .unwrap(); + + assert_eq!(result.git_version.as_deref(), Some("git version 2.53.0")); + assert!(result.repository_verified); + assert!(Path::new(&result.workspace_root).is_absolute()); + let plans = runner.plans.lock().unwrap(); + assert_eq!(plans.len(), 2); + assert_eq!(plans[0].args, [OsString::from("--version")]); + assert_eq!(plans[1].args[0], OsString::from("ls-remote")); + assert!(plans[1] + .args + .contains(&OsString::from("https://github.com/owner/repo.git"))); +} + +#[tokio::test] +async fn workspace_probe_surfaces_the_actionable_git_stderr() { + let temporary = tempfile::tempdir().unwrap(); + let runner = Arc::new(FakeRunner::with_results([Err(LoopxProcessError::Exited { + code: Some(128), + stdout_tail: Vec::new(), + stderr_tail: vec!["fatal: unable to access repository".to_string()], + payload: None, + })])); + let service = LoopxWorkspaceService::with_runner( + LoopxWorkspaceServiceConfig::new(temporary.path().join("workspaces"), "git"), + runner, + Arc::new(NoopLoopxProcessObserver), + ); + + let error = service + .probe(LoopxWorkspaceProbeRequest { + operation_id: "workspace-probe-error".to_string(), + repository: None, + }) + .await + .unwrap_err(); + + assert!(error.message.contains("status Some(128)")); + assert!(error.message.contains("fatal: unable to access repository")); +} + +#[test] +fn managed_process_fixture() { + match std::env::var("BITFUN_LOOPX_PROCESS_FIXTURE").as_deref() { + Ok("exit") => std::process::exit(23), + Ok("sleep") => std::thread::sleep(Duration::from_secs(60)), + Ok("stderr") => eprintln!("fixture progress line"), + _ => {} + } +} + +fn fixture_plan(kind: &str, deadline: Duration) -> LoopxCommandPlan { + let mut environment = BTreeMap::new(); + environment.insert( + OsString::from("BITFUN_LOOPX_PROCESS_FIXTURE"), + OsString::from(kind), + ); + LoopxCommandPlan { + operation_id: format!("fixture-{kind}"), + executable: std::env::current_exe().unwrap(), + args: ["--exact", "managed_process_fixture", "--nocapture"] + .into_iter() + .map(OsString::from) + .collect(), + current_dir: None, + environment, + deadline, + terminate_grace: Duration::from_millis(50), + } +} + +#[tokio::test] +async fn child_exit_is_reported_immediately() { + let started = Instant::now(); + let error = SystemLoopxProcessRunner + .run( + fixture_plan("exit", Duration::from_secs(5)), + CancellationToken::new(), + &NoopLoopxProcessObserver, + ) + .await + .unwrap_err(); + + assert!(started.elapsed() < Duration::from_secs(2)); + assert!(matches!( + error, + LoopxProcessError::Exited { code: Some(23), .. } + )); +} + +#[tokio::test] +async fn deadline_terminates_the_managed_process_tree() { + let started = Instant::now(); + let error = SystemLoopxProcessRunner + .run( + fixture_plan("sleep", Duration::from_millis(50)), + CancellationToken::new(), + &NoopLoopxProcessObserver, + ) + .await + .unwrap_err(); + + assert!(started.elapsed() < Duration::from_secs(2)); + assert!(matches!(error, LoopxProcessError::Timeout { .. })); +} + +#[tokio::test] +async fn stderr_is_streamed_as_progress_before_success() { + let observer = RecordingProcessObserver::default(); + SystemLoopxProcessRunner + .run( + fixture_plan("stderr", Duration::from_secs(5)), + CancellationToken::new(), + &observer, + ) + .await + .unwrap(); + + let progress = observer.0.lock().unwrap(); + assert!(progress.iter().any(|event| { + event.stage == LoopxProgressStage::Stderr && event.message.contains("fixture progress line") + })); + assert_eq!( + progress.last().map(|event| event.stage), + Some(LoopxProgressStage::Exited) + ); +} + +#[test] +fn command_sources_keep_packaged_managed_and_system_paths_distinct() { + assert_ne!( + LoopxCommandSource::PackagedBundle, + LoopxCommandSource::ManagedSource + ); + assert_ne!( + LoopxCommandSource::ManagedSource, + LoopxCommandSource::FixedSystemCommand + ); +} diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 1e24bb24d2..86cecb5f9b 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -182,6 +182,15 @@ } ], "implementationOnlyCommands": { + "loopxControlPlane": [ + "miniapp_loopx_action", + "miniapp_loopx_attach", + "miniapp_loopx_create_task", + "miniapp_loopx_events_since", + "miniapp_loopx_list_models", + "miniapp_loopx_resolve_intake", + "miniapp_loopx_turn_output_since" + ], "legacyUpdateCompatibility": [ "install_update" ], @@ -9371,6 +9380,9 @@ "miniapp": { "capabilityId": "feature.miniapps" }, + "miniapp_loopx": { + "capabilityId": "feature.miniapps" + }, "ssh": { "capabilityId": "feature.remote-workspaces" }, diff --git a/src/web-ui/src/app/hooks/dialogCompletionNotifyPolicy.ts b/src/web-ui/src/app/hooks/dialogCompletionNotifyPolicy.ts index 75cdc44d9a..cea35241d9 100644 --- a/src/web-ui/src/app/hooks/dialogCompletionNotifyPolicy.ts +++ b/src/web-ui/src/app/hooks/dialogCompletionNotifyPolicy.ts @@ -34,6 +34,16 @@ export function shouldSendDialogCompletionNotification({ return false; } + // MiniApp headless agent runs (including the builtin LoopX driver) are + // unattended by design: every turn completes quietly and the next one is + // scheduled by the host. A completion toast per turn would train the owner + // to ignore notifications. Human attention is requested only at owner + // decision points, which the MiniApp surface itself raises through the + // host notification bridge when a user gate appears. + if (sessionKind === 'miniapp') { + return false; + } + return true; } diff --git a/src/web-ui/src/app/hooks/useDialogCompletionNotify.test.ts b/src/web-ui/src/app/hooks/useDialogCompletionNotify.test.ts index 43aca62670..3a5872c859 100644 --- a/src/web-ui/src/app/hooks/useDialogCompletionNotify.test.ts +++ b/src/web-ui/src/app/hooks/useDialogCompletionNotify.test.ts @@ -65,6 +65,19 @@ describe('shouldSendDialogCompletionNotification', () => { ).toBe(false); }); + it('suppresses miniapp headless agent completions (LoopX turns notify only at owner gates)', () => { + expect( + shouldSendDialogCompletionNotification({ + event: event(), + session: session({ + sessionKind: 'miniapp', + }), + isBackground: true, + notificationsEnabled: true, + }), + ).toBe(false); + }); + it('suppresses notifications when the session is not available locally', () => { expect( shouldSendDialogCompletionNotification({ diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx index 571a93e801..4aafff0afd 100644 --- a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx @@ -6,6 +6,7 @@ import React, { Suspense, lazy, useState } from 'react'; import { TabGroup } from '@openbitfun/ui'; import { useI18n } from '@/infrastructure/i18n'; +import { isTauriRuntime } from '@/infrastructure/runtime'; import './MiniAppGalleryScene.scss'; const MiniAppLibraryView = lazy(() => import('./views/MiniAppLibraryView')); @@ -37,6 +38,22 @@ const MiniAppGalleryScene: React.FC = () => { ); + // MiniApps run inside the desktop host (worker pool + built-in seeding live + // there). On server/web surfaces the scene degrades loudly instead of + // rendering an empty gallery. + if (!isTauriRuntime()) { + return ( +
+
+
+

{t('unsupported.title')}

+

{t('unsupported.body')}

+
+
+
+ ); + } + return (
diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/loopxBridgeProtocol.ts b/src/web-ui/src/app/scenes/miniapps/hooks/loopxBridgeProtocol.ts new file mode 100644 index 0000000000..d212085139 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/hooks/loopxBridgeProtocol.ts @@ -0,0 +1,314 @@ +import type { + LoopxActionKind, + LoopxActionRequest, + LoopxAttachRequest, + LoopxCreateTaskRequest, + LoopxEventsSinceRequest, + LoopxIssueKey, + LoopxItemKind, + LoopxPermissionScope, + LoopxResolveIntakeRequest, + LoopxTurnOutputSinceRequest, +} from '@/infrastructure/api/service-api/LoopxAPI'; + +export const LOOPX_BUILTIN_APP_ID = 'builtin-bitfun-loopx'; + +type LoopxBridgeCall = + | { kind: 'attach'; request: LoopxAttachRequest } + | { kind: 'listModels' } + | { kind: 'resolveIntake'; request: LoopxResolveIntakeRequest } + | { kind: 'createTask'; request: LoopxCreateTaskRequest } + | { kind: 'action'; request: LoopxActionRequest } + | { kind: 'eventsSince'; request: LoopxEventsSinceRequest } + | { kind: 'turnOutputSince'; request: LoopxTurnOutputSinceRequest }; + +const LOOPX_METHODS = new Set([ + 'loopx.attach', + 'loopx.listModels', + 'loopx.resolveIntake', + 'loopx.createTask', + 'loopx.action', + 'loopx.eventsSince', + 'loopx.turnOutputSince', +]); + +const HOST_CONTROLLED_KEYS = new Set([ + 'argv', + 'argvprefix', + 'binary', + 'cliargs', + 'command', + 'cwd', + 'executable', + 'executiondomain', + 'peerdeviceid', + 'projectdir', + 'workspacepath', + 'worktreepath', +]); + +const ITEM_KINDS = new Set(['issue', 'pr']); +const PERMISSION_SCOPES = new Set([ + 'workspace_read', + 'workspace_write', + 'git_local', + 'github_read', + 'agent_execution', + 'publish', + 'public_comment', + 'pull_request', + 'merge', + 'production_action', +]); +const ACTION_KINDS = new Set([ + 'pause', +'abort', + 'resume', + 'resume_repository', + 'reset_all', + 'approve', + 'reject', + 'archive', + 'restore', + 'install_loopx', + 'retry_environment', +]); + +function normalizedKey(key: string): string { + return key.replace(/_/g, '').toLowerCase(); +} + +function assertNoHostControlledFields(value: unknown, path = 'params'): void { + if (Array.isArray(value)) { + value.forEach((item, index) => assertNoHostControlledFields(item, `${path}[${index}]`)); + return; + } + if (!value || typeof value !== 'object') return; + + for (const [key, child] of Object.entries(value)) { + if (HOST_CONTROLLED_KEYS.has(normalizedKey(key))) { + throw new Error( + `LoopX MiniApps cannot provide host-controlled field '${path}.${key}'. ` + + 'Execution targets, filesystem paths, and CLI arguments are selected by the host.', + ); + } + assertNoHostControlledFields(child, `${path}.${key}`); + } +} + +function assertAllowedKeys( + value: Record, + allowed: readonly string[], + path: string, +): void { + const allowedSet = new Set(allowed); + const unsupported = Object.keys(value).find((key) => !allowedSet.has(key)); + if (unsupported) { + throw new Error(`Unsupported LoopX parameter '${path}.${unsupported}'.`); + } +} + +function asRecord(value: unknown, path: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`LoopX parameter '${path}' must be an object.`); + } + return value as Record; +} + +function requiredString(value: unknown, path: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`LoopX parameter '${path}' must be a non-empty string.`); + } + return value; +} + +function optionalString(value: unknown, path: string): string | undefined { + if (value == null) return undefined; + return requiredString(value, path); +} + +function unsignedInteger(value: unknown, path: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`LoopX parameter '${path}' must be a non-negative safe integer.`); + } + return value; +} + +function optionalUnsignedInteger(value: unknown, path: string): number | undefined { + if (value == null) return undefined; + return unsignedInteger(value, path); +} + +function requiredBoolean(value: unknown, path: string): boolean { + if (typeof value !== 'boolean') { + throw new Error(`LoopX parameter '${path}' must be a boolean.`); + } + return value; +} + +function parseIssueKey(value: unknown, path: string): LoopxIssueKey { + const item = asRecord(value, path); + assertAllowedKeys(item, ['repository', 'kind', 'number'], path); + + const kind = item.kind; + if (typeof kind !== 'string' || !ITEM_KINDS.has(kind as LoopxItemKind)) { + throw new Error(`LoopX parameter '${path}.kind' must be 'issue' or 'pr'.`); + } + + return { + repository: parseRepositoryKey(item.repository, `${path}.repository`), + kind: kind as LoopxItemKind, + number: unsignedInteger(item.number, `${path}.number`), + }; +} + +function parseRepositoryKey(value: unknown, path: string) { + const repository = asRecord(value, path); + assertAllowedKeys(repository, ['host', 'owner', 'repository'], path); + return { + host: requiredString(repository.host, `${path}.host`), + owner: requiredString(repository.owner, `${path}.owner`), + repository: requiredString(repository.repository, `${path}.repository`), + }; +} + +function parsePermissionScopes(value: unknown): LoopxPermissionScope[] { + if (!Array.isArray(value)) { + throw new Error("LoopX parameter 'params.grantedScopes' must be an array."); + } + return value.map((scope, index) => { + if (typeof scope !== 'string' || !PERMISSION_SCOPES.has(scope as LoopxPermissionScope)) { + throw new Error(`Unsupported LoopX permission scope at 'params.grantedScopes[${index}]'.`); + } + return scope as LoopxPermissionScope; + }); +} + +export function isLoopxBridgeMethod(method: string): boolean { + return method.startsWith('loopx.'); +} + +export function parseLoopxBridgeCall( + method: string, + rawParams: Record, +): LoopxBridgeCall { + if (!LOOPX_METHODS.has(method)) { + throw new Error(`Unknown LoopX method: ${method}`); + } + assertNoHostControlledFields(rawParams); + + if (method === 'loopx.attach') { + assertAllowedKeys(rawParams, ['knownStreamId', 'afterCursor', 'resumeDetected'], 'params'); + return { + kind: 'attach', + request: { + knownStreamId: optionalString(rawParams.knownStreamId, 'params.knownStreamId'), + afterCursor: optionalUnsignedInteger(rawParams.afterCursor, 'params.afterCursor'), + resumeDetected: rawParams.resumeDetected === undefined + ? undefined + : requiredBoolean(rawParams.resumeDetected, 'params.resumeDetected'), + }, + }; + } + + if (method === 'loopx.listModels') { + assertAllowedKeys(rawParams, [], 'params'); + return { kind: 'listModels' }; + } + + if (method === 'loopx.resolveIntake') { + assertAllowedKeys(rawParams, ['input', 'modelId'], 'params'); + return { + kind: 'resolveIntake', + request: { + input: requiredString(rawParams.input, 'params.input'), + modelId: requiredString(rawParams.modelId, 'params.modelId'), + }, + }; + } + + if (method === 'loopx.createTask') { + assertAllowedKeys(rawParams, [ + 'clientRequestId', + 'previewFingerprint', + 'selectedItems', + 'modelId', + 'grantedScopes', + 'retryTerminal', + ], 'params'); + if (!Array.isArray(rawParams.selectedItems) || rawParams.selectedItems.length === 0) { + throw new Error("LoopX parameter 'params.selectedItems' must contain at least one item."); + } + return { + kind: 'createTask', + request: { + clientRequestId: requiredString(rawParams.clientRequestId, 'params.clientRequestId'), + previewFingerprint: requiredString( + rawParams.previewFingerprint, + 'params.previewFingerprint', + ), + selectedItems: rawParams.selectedItems.map((item, index) => + parseIssueKey(item, `params.selectedItems[${index}]`)), + modelId: requiredString(rawParams.modelId, 'params.modelId'), + grantedScopes: parsePermissionScopes(rawParams.grantedScopes), + retryTerminal: requiredBoolean(rawParams.retryTerminal, 'params.retryTerminal'), + }, + }; + } + + if (method === 'loopx.action') { + assertAllowedKeys(rawParams, [ + 'taskId', + 'repository', + 'action', + 'clientRequestId', + 'expectedRevision', + 'gateId', + 'note', + ], 'params'); + if ( + typeof rawParams.action !== 'string' + || !ACTION_KINDS.has(rawParams.action as LoopxActionKind) + ) { + throw new Error("Unsupported LoopX action at 'params.action'."); + } + return { + kind: 'action', + request: { + taskId: optionalString(rawParams.taskId, 'params.taskId'), + repository: rawParams.repository == null + ? undefined + : parseRepositoryKey(rawParams.repository, 'params.repository'), + action: rawParams.action as LoopxActionKind, + clientRequestId: requiredString(rawParams.clientRequestId, 'params.clientRequestId'), + expectedRevision: unsignedInteger(rawParams.expectedRevision, 'params.expectedRevision'), + gateId: optionalString(rawParams.gateId, 'params.gateId'), + note: optionalString(rawParams.note, 'params.note'), + }, + }; + } + + if (method === 'loopx.eventsSince') { + assertAllowedKeys(rawParams, ['streamId', 'afterCursor', 'limit'], 'params'); + return { + kind: 'eventsSince', + request: { + streamId: requiredString(rawParams.streamId, 'params.streamId'), + afterCursor: unsignedInteger(rawParams.afterCursor, 'params.afterCursor'), + limit: optionalUnsignedInteger(rawParams.limit, 'params.limit'), + }, + }; + } + + assertAllowedKeys(rawParams, ['taskId', 'turnId', 'streamId', 'afterCursor', 'limit'], 'params'); + return { + kind: 'turnOutputSince', + request: { + taskId: requiredString(rawParams.taskId, 'params.taskId'), + turnId: optionalString(rawParams.turnId, 'params.turnId'), + streamId: optionalString(rawParams.streamId, 'params.streamId'), + afterCursor: unsignedInteger(rawParams.afterCursor, 'params.afterCursor'), + limit: optionalUnsignedInteger(rawParams.limit, 'params.limit'), + }, + }; +} diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.test.tsx b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.test.tsx index 34be7c7ed5..79789da95e 100644 --- a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.test.tsx +++ b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.test.tsx @@ -18,8 +18,18 @@ import { useMiniAppBridge } from './useMiniAppBridge'; const mocks = vi.hoisted(() => ({ activeTabId: 'miniapp:market-lens', + peerModeActive: false, + workspaceKind: undefined as 'remote' | undefined, agentEnsureSession: vi.fn(), agentRun: vi.fn(), + getCustomizationMetadata: vi.fn(), + loopxAttach: vi.fn(), + loopxListModels: vi.fn(), + loopxResolveIntake: vi.fn(), + loopxCreateTask: vi.fn(), + loopxAction: vi.fn(), + loopxEventsSince: vi.fn(), + loopxTurnOutputSince: vi.fn(), apiListen: vi.fn(), openMainSession: vi.fn(), addExternalSession: vi.fn(), @@ -30,6 +40,19 @@ vi.mock('@/infrastructure/api/service-api/MiniAppAPI', () => ({ miniAppAPI: { agentEnsureSession: mocks.agentEnsureSession, agentRun: mocks.agentRun, + getCustomizationMetadata: mocks.getCustomizationMetadata, + }, +})); + +vi.mock('@/infrastructure/api/service-api/LoopxAPI', () => ({ + loopxAPI: { + attach: mocks.loopxAttach, + listModels: mocks.loopxListModels, + resolveIntake: mocks.loopxResolveIntake, + createTask: mocks.loopxCreateTask, + action: mocks.loopxAction, + eventsSince: mocks.loopxEventsSince, + turnOutputSince: mocks.loopxTurnOutputSince, }, })); @@ -40,7 +63,14 @@ vi.mock('@tauri-apps/plugin-dialog', () => ({ })); vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ - useCurrentWorkspace: () => ({ workspacePath: '/repo' }), + useCurrentWorkspace: () => ({ + workspacePath: '/repo', + workspace: mocks.workspaceKind ? { workspaceKind: mocks.workspaceKind } : null, + }), +})); + +vi.mock('@/infrastructure/peer-device/peerModeFlag', () => ({ + isPeerDeviceModeActive: () => mocks.peerModeActive, })); vi.mock('@/infrastructure/theme/hooks/useTheme', () => ({ @@ -90,6 +120,9 @@ vi.mock('@/app/stores/sceneStore', () => ({ vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), warn: vi.fn(), error: vi.fn(), }), @@ -116,6 +149,23 @@ function BridgeHarness() { return