From 866d2b5b59b94c52a89451e5db9aa3801c355c08 Mon Sep 17 00:00:00 2001 From: limityan Date: Tue, 19 May 2026 10:51:47 +0800 Subject: [PATCH] refactor(product-domains): add runtime facade guardrails Move MiniApp runtime state persistence behind product-domain facade contracts and add function-agent runtime facade/adapter boundaries without rewiring product execution paths. Update decomposition guardrails and module AGENTS docs to keep runtime migration constraints explicit. --- AGENTS-CN.md | 12 +- AGENTS.md | 89 +---- docs/architecture/core-decomposition.md | 23 +- docs/plans/core-decomposition-plan.md | 12 +- scripts/check-core-boundaries.mjs | 90 ++++- src/apps/desktop/AGENTS-CN.md | 1 + src/apps/desktop/AGENTS.md | 2 + src/crates/acp/AGENTS-CN.md | 24 ++ src/crates/acp/AGENTS.md | 27 ++ src/crates/ai-adapters/AGENTS.md | 29 +- src/crates/core/AGENTS-CN.md | 4 +- src/crates/core/AGENTS.md | 6 + .../core/src/function_agents/port_adapters.rs | 64 +++- src/crates/core/src/miniapp/manager.rs | 150 ++++++--- src/crates/product-domains/AGENTS-CN.md | 15 + src/crates/product-domains/AGENTS.md | 9 +- .../src/function_agents/ports.rs | 165 ++++++++- .../product-domains/src/miniapp/ports.rs | 103 ++++++ .../tests/function_agent_contracts.rs | 228 +++++++++++-- .../tests/miniapp_contracts.rs | 318 ++++++++++++++++-- 20 files changed, 1176 insertions(+), 195 deletions(-) create mode 100644 src/crates/acp/AGENTS-CN.md create mode 100644 src/crates/acp/AGENTS.md diff --git a/AGENTS-CN.md b/AGENTS-CN.md index ba0b17f81..9a43707ec 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -22,6 +22,7 @@ BitFun 是一个由 Rust workspace 与共享 React 前端组成的项目。 | 产品领域 crate | `src/crates/product-domains` | [AGENTS.md](src/crates/product-domains/AGENTS.md) | | Transport 适配层 | `src/crates/transport` | (使用 core 指南) | | API layer | `src/crates/api-layer` | (使用 core 指南) | +| ACP 集成 | `src/crates/acp` | [AGENTS.md](src/crates/acp/AGENTS.md) | | AI adapters | `src/crates/ai-adapters` | [AGENTS.md](src/crates/ai-adapters/AGENTS.md) | | 桌面应用 | `src/apps/desktop` | [AGENTS.md](src/apps/desktop/AGENTS.md) | | Server | `src/apps/server` | (使用 core 指南) | @@ -114,14 +115,13 @@ await api.invoke('your_command', { request: { ... } }); 任何 `bitfun-core` 拆解、feature 边界、依赖边界或 Rust 构建提速重构, 都必须先阅读 [`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md)。 -该文档定义产品行为不变量、crate 归属目标、禁止依赖方向、feature 安全规则和里程碑验证门禁。 +顶层文档只作为入口;模块级 ownership 细节应放到离代码最近的模块 `AGENTS.md`。 -### Tool 归属护栏 +仓库级拆解规则: -- `src/crates/agent-tools` 拥有轻量 tool contract,以及 generic registry / dynamic-provider container。 -- `src/crates/core/src/agentic/tools` 当前负责产品工具组装、`dyn Tool` 适配、snapshot decoration、tool exposure / manifest resolution,以及按需工具说明发现(`GetToolSpec`)。 -- `ToolUseContext` 与具体工具实现继续留在 core,直到有已评审的 port/provider 设计和等价测试。 -- Tool 迁移必须保持 expanded/collapsed exposure、prompt 可见 manifest、`ToolUseContext.unlocked_collapsed_tools`,以及 desktop/MCP/ACP tool catalog 行为等价。 +- 不要把 DTO / contract 抽取误判为 runtime owner 已迁移。 +- 产品表面可以有差异;共享稳定 facts 或 ports,不共享 UI、protocol、lifecycle 或平台实现。 +- 迁移 runtime owner 必须有评审过的 port/provider 设计、旧路径兼容、行为等价测试;如果可能改变行为边界,还需要先确认。 ### DeepReview 护栏 diff --git a/AGENTS.md b/AGENTS.md index 9cf420bd2..0215a147a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ Repository rule: **keep product logic platform-agnostic, then expose it through | Product domains | `src/crates/product-domains` | [AGENTS.md](src/crates/product-domains/AGENTS.md) | | Transport adapters | `src/crates/transport` | (use core guide) | | API layer | `src/crates/api-layer` | (use core guide) | +| ACP integration | `src/crates/acp` | [AGENTS.md](src/crates/acp/AGENTS.md) | | AI adapters | `src/crates/ai-adapters` | [AGENTS.md](src/crates/ai-adapters/AGENTS.md) | | Desktop app | `src/apps/desktop` | [AGENTS.md](src/apps/desktop/AGENTS.md) | | Server | `src/apps/server` | (use core guide) | @@ -114,83 +115,17 @@ await api.invoke('your_command', { request: { ... } }); For any `bitfun-core` decomposition, feature-boundary, dependency-boundary, or Rust build-speed refactor, read [`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md) -before editing. The guardrail document defines product-behavior invariants, -crate ownership targets, forbidden dependency directions, feature safety rules, -and milestone verification gates. - -### Tool ownership guardrails - -- `src/crates/agent-tools` owns lightweight tool contracts, portable tool - context facts/provider contracts, pure manifest/exposure contracts, and - generic registry / static-provider / dynamic-provider container contracts. -- `src/crates/tool-packs` may expose planned tool-pack feature-group scaffold - metadata, but it must not own concrete tool implementations or product - manifest runtime until a reviewed provider migration exists. -- `src/crates/core/src/agentic/tools` owns product tool provider assembly - (`static_providers.rs`), `dyn Tool` adaptation, snapshot decoration, runtime - manifest assembly / context filtering, and on-demand tool spec discovery - execution (`GetToolSpec`) for now. -- Keep `ToolUseContext` and concrete tool implementations in core until a - reviewed port/provider design and equivalence tests exist. A portable - `ToolContextFacts` projection via `PortableToolContextProvider` may cross - crate boundaries, but runtime handles and service objects must stay in core. -- Tool migrations must preserve expanded/collapsed exposure, prompt-visible - manifests, `ToolUseContext.unlocked_collapsed_tools`, and desktop/MCP/ACP - tool catalog behavior. - -### Latest-main runtime anchors - -- Agent registry migration must preserve mode-scoped subagent availability, - hidden/custom/review grouping, desktop subagent API semantics, and CLI - mode-aware `/subagents` list/config behavior, including `Multitask` mode - and the built-in `GeneralPurpose` subagent. -- Background subagent task delivery remains core agent-runtime behavior: - `Task.run_in_background` must preserve parent metadata, workspace routing, - running-turn injection, and idle-session follow-up turn delivery. -- DeepResearch report finalization currently relies on the core citation - renumber hook; do not move it without preserving `report.md`, - `citations.md`, `display_map.json`, and rejected-citation handling. -- Workspace/search refactors must preserve remote workspace startup guards, - remote flashgrep fallback, and search preview/context mapping. -- ACP timeout handling and Web operation-diff fallback are product-surface - behavior; share facts through contracts, not UI/protocol implementation. -- Web startup trace, deferred background scheduling, narrow tool startup, and - non-blocking Flow Chat history hydration are Web product-surface behavior; - do not move those orchestration paths into core contract crates. -- Built-in MiniApp seeding, content-hash update markers, customization-update - metadata, and host dispatch execution remain core-owned runtime behavior - until a reviewed MiniApp runtime migration exists. - -### Services/product owner closure - -- Remote-SSH path, session identity, mirror path, and unresolved-session layout - helpers belong in `bitfun-services-integrations`; core may inject - `PathManager` and hold SSH manager / remote FS / terminal assembly. -- MiniApp storage/draft/import file shape, import fallback payload, lifecycle - state-transition helpers, runtime search-plan helpers, customization metadata - policy including built-in update/decline decisions, and function-agent pure - prompt/diff preparation helpers belong in `bitfun-product-domains`; core - keeps filesystem IO, worker runtime, built-in source-hash lookup, - `PathManager`, Git/AI calls, prompt templates, JSON extraction, error - mapping, and port adapters until reviewed runtime migrations exist. - Before moving those runtime owners, keep the core-owned MiniApp manager and - function-agent Git/AI boundary snapshots passing. -- Remote-connect port baselines live in `bitfun-runtime-ports` and - `bitfun-services-integrations`; tracker state and tracker event reduction - belong in `bitfun-services-integrations`. Remote command/response wire DTOs, - remote model catalog DTOs, poll-response assembly helpers, and model-catalog - poll delta policy also belong there. Pure remote image-context - fallback/preference, restore-target, cancel-decision, and remote file-transfer - size/chunk/name helpers also belong in `bitfun-services-integrations`, while - core still owns the adapter back to `ImageContextData`, dispatcher assembly, - session restore execution, file IO/path resolution, terminal pre-warm, and - product execution routing. Further remote runtime owner migration must - preserve the existing migration snapshots for command/response shape, - restore, active-turn polling, cancel decisions, image context - fallback/preference, tracker fanout, file transfer, and RemoteRelay/Bot queue - policy. - `AgentSubmissionPort` still rejects generic attachments until - image/multimodal equivalence tests and a runtime migration plan are reviewed. +before editing. Keep this file as an entry point; put module-specific ownership +details in the nearest module `AGENTS.md`. + +Repository-level decomposition rules: + +- Do not confuse DTO/contract extraction with runtime owner migration. +- Product surfaces may diverge; share stable facts or ports, not UI, protocol, + lifecycle, or platform implementation. +- Moving runtime ownership requires a reviewed port/provider design, old-path + compatibility, behavior equivalence tests, and explicit confirmation when a + behavior boundary could change. ### DeepReview guardrails diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index b32a83915..626ea31c4 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -158,6 +158,16 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate - ACP startup timeout 和 operation diff fallback 属于 ACP/Web product surface 行为;后续只能通过 stable contract 共享事实,不得把 ACP timeout、tool diff fallback 或 Web diff rendering 下沉到 core-types、runtime-ports、agent-tools 等 contract crate。 +- 最新主干的 remote ACP agents config 继续强化 ACP/app adapter owner:remote workspace + 复用 local ACP config,并通过 ACP client manager、remote shell、remote capability store 与 + workspace menu 串联。后续只能把 environment / capability facts 抽成 contract;ACP config + persistence、remote probing 和 workspace surface selection 仍留在 ACP/app surface。 +- 最新主干的 usage/cache 与 OpenAI Responses 修复提高了 AI adapter / stream 迁移门槛。 + `cached_content_token_count` 表示 cache reads / hits,`cache_creation_token_count` 与 + DeepSeek `prompt_cache_hit_tokens` mapping 必须保留为独立语义,不能在 `agent-stream`、 + `session_usage` 或 runtime budget 迁移中重新合并为 total usage。OpenAI Responses / + Codex ChatGPT flat tool schema 是 provider adapter serialization,不应写死进 + `bitfun-agent-tools` 的 provider-neutral manifest contract。 - 最新 Web 启动优化把 startup trace、deferred background scheduler、narrow tool initializer 与历史会话 hydrate 放在 web app / Flow Chat surface。后续不能为了“共享启动能力”把 `startupTrace`、`backgroundTaskScheduler`、history hydration 或 tool warmup 下沉到 core contract @@ -165,6 +175,9 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate - 最新 CLI 重构新增大量 TUI、theme、selector、dialog 和 chat-state 代码,但仍位于 `src/apps/cli`。后续 core decomposition 只能通过产品 check 验证 CLI 仍可组装,不应把 CLI presentation 依赖迁入 core-types、runtime-ports 或 agent-tools。 +- 最新 desktop close button 默认最小化到 system tray 是 desktop lifecycle surface 行为; + 后续若调整 desktop app lifecycle / window state,只能用 desktop product check 验证, + 不应把 close/minimize 策略抽入 shared core service。 - Tool framework crate 不得依赖 concrete service implementation。 - 产品 crate 可以通过显式 product feature 组装完整 runtime。 - 后续迁移必须先按风险分层处理: @@ -223,11 +236,11 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate - 已合入的 semantic baseline 已补 config failure、catalog replacement invalidation、沿用既有 list-changed helper baseline、dynamic manifest order/metadata、tool manifest / `GetToolSpec`、MiniApp storage layout adapter 等价和 remote search fallback gate;这些都是 behavior-locking tests,不移动 runtime owner。 -- 当前 tool manifest contract closure PR 只把 `ToolExposure`、`GetToolSpec` 名称、纯 manifest +- 已合入的 tool manifest contract closure 只把 `ToolExposure`、`GetToolSpec` 名称、纯 manifest policy、collapsed prompt stub 与 prompt-visible ordering 归入 `bitfun-agent-tools`,并让 core runtime manifest resolver 委托这些纯 helper;不迁移 `ToolUseContext`、registry snapshot、 context-aware schema/description、`GetToolSpecTool` 执行、unlock state 或 concrete tool implementation。 - 该 PR 闭环后,后续不应再插入 baseline-only PR 才开始 runtime owner 迁移;下一组 PR 应直接以 + 该阶段闭环后,后续不应再插入 baseline-only PR 才开始 runtime owner 迁移;下一组 PR 应直接以 单一 owner 为单位移动实际 runtime,并沿用本节等价测试和边界脚本。 - 已合入的 `Services/Product Runtime Owner Closure` 只收口已经有 port/contract 保护的低风险 owner: remote-SSH session identity / mirror path / unresolved-session layout 归属 @@ -241,6 +254,12 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate function-agent staged diff snapshot、AI response JSON extraction 与 error mapping 仍被记录为 core-owned 行为。后续若继续移动这些 runtime owner,必须以这些快照为 行为等价基线。 +- 当前 `product-domains` runtime port/facade closure 只迁移 port-backed owner + orchestration:MiniApp 的 deps/restart/recompile/sync/rollback 状态持久化可经 + storage facade 执行,function-agent commit / work-state facade 可基于 Git/AI port + 组装结果。core 仍持有 MiniApp filesystem IO、compiler 调度、worker process、host + dispatch、built-in seed/update,以及 function-agent Git/AI service、prompt template、 + JSON extraction 和 error mapping;现有 function-agent 产品路径尚未切到新 facade。 ## 产品表面边界(Product Surface Boundary) diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index d5f429ebc..795df0fd1 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -1128,6 +1128,7 @@ product-full = ["miniapp", "function-agents"] - 2026-05-18 update: MiniApp draft manifest/response DTO, draft/customization storage path helpers, import layout / fallback payload contracts, manager lifecycle state-transition helpers, runtime executable search-plan helpers, customization draft-apply metadata policy, and built-in update/decline metadata decisions have been moved to `bitfun-product-domains::miniapp`; core continues to own draft/import filesystem IO, compile orchestration, built-in asset seeding/source-hash lookup, host dispatch execution, `PathManager` integration, worker process execution, and compatibility facades. The current PR also records core-owned MiniApp import / sync / recompile / rollback / dependency-state behavior as migration-before tests, including the existing `sync_from_fs` snapshot boundary. - 已迁移到 `bitfun-product-domains::function_agents`:公共 `common` 类型、git/startchat function-agent 的纯 DTO 类型、git function-agent 的纯路径 / 变更分类 / commit summary / message assembly / prompt format / commit type parser / AI response parsing policy、startchat prompt / action / AI response parsing policy / git porcelain / diff combine / time-of-day helper、Git/AI port contract,以及只读本地文件的 project context analyzer;core-owned Git snapshot adapter 已由等价测试覆盖,AI client、Git service、prompt template、AI request、JSON extraction、错误映射与分析运行逻辑仍留在 core。 - 2026-05-18 update: Git function-agent diff truncation and commit prompt preparation are now owner-crate helpers used by core; AI client calls, prompt template ownership, JSON extraction, error mapping, and runtime analysis execution remain core-owned. The current PR adds focused core snapshots for staged-only Git commit diff collection and AI response JSON extraction / error mapping before any Git/AI runtime migration. +- 2026-05-19 update: `bitfun-product-domains` now owns port-backed MiniApp runtime-state and function-agent runtime facades. Core delegates only MiniApp storage-backed lifecycle persistence through the MiniApp facade; compilation, source reads, storage IO adapter, worker process execution, host dispatch, built-in seeding, Git service calls, AI calls, prompt templates, JSON extraction, and concrete error mapping remain core-owned. The function-agent facade and core AI adapter shape are contract-ready but existing product call paths are not rewired yet. - boundary check 已补充 product-domain owner anchor:`MiniAppStoragePort` / `MiniAppRuntimePort` 的 core adapter、MiniApp host/customization 纯 contract、MiniApp manager preflight tests、function-agent Git adapter 与 AI response parsing helper 必须存在,防止把 port contract 或 pure parser 误读成 storage IO、worker process、host dispatch、customization draft runtime、Git/AI service runtime 已完成迁移。 - miniapp runtime/storage/manager/host dispatch/exporter/builtin 与 function-agent 运行逻辑继续迁移前,需要先确认 agent/tool/provider port 和 Git/AI service 边界。 @@ -1654,7 +1655,7 @@ P2 后产品表面契约轨道(contract-only): - `default = []` 必须是单独 PR,且只在所有产品 crate 显式启用完整 runtime 后评估。 - 不允许把 facade 变成新的业务实现聚合。 -**P3 进入条件与最新主干补充(2026-05-18):** +**P3 进入条件与最新主干补充(2026-05-19):** - P3 只能在 P2 剩余迁移闭环后启动:重 service 迁移、`ToolUseContext` / runtime manifest assembly / `GetToolSpec` 执行 / concrete tool implementation 迁移、product registry / provider assembly、miniapp/function-agent 运行逻辑迁移都必须先完成或显式保留为 core-owned runtime;generic registry / static-provider / dynamic-provider container 和纯 manifest/exposure 契约已在 agent-tools 低风险外移中完成。 - 最近 `origin/main` 的 Deep Review 变更增加了 context profile、evidence ledger、capacity/cost/queue 控制、`deep_review_run_manifest` / `deep_review_cache`、以及 review-team UI orchestration;最新主干还补充了 agent-stream tool-call dedupe、search remote/fallback、session rollback persistence、remote workspace compatibility guard、ACP startup timeout / operation diff fallback 和 companion typewriter。P3 facade 收敛前必须确认这些行为要么仍由 core product runtime assembly 或对应 product surface 拥有,要么已有对应 owner crate + port/provider 合约和等价测试。 @@ -1663,8 +1664,11 @@ P2 后产品表面契约轨道(contract-only): - 最新主干的 on-demand tool spec discovery 将 `manifest_resolver`、`GetToolSpecTool` 执行、collapsed-tool catalog 和 `ToolUseContext.unlocked_collapsed_tools` 接入 agent prompt / execution pipeline / desktop-MCP-ACP catalog。P3 facade 收敛前必须把这些显式保留在 core product tool runtime,或先完成等价快照与 port/provider 设计后再迁移;`ToolExposure`、`GetToolSpec` 名称、collapsed-tool prompt stub 和 manifest ordering 仅作为纯契约保留在 `bitfun-agent-tools`。 - 最新主干的 search result rendering / context handling 与 remote workspace compatibility guard 要求后续 `service::search`、`workspace` 或 remote runtime 迁移保留 startup restored workspace guard、remote runtime ensure、remote flashgrep FilesWithMatches fallback、preview split 和 local/remote fallback contract。 - ACP startup timeout 和 Web file-operation diff fallback 属于 product surface 行为:可以在后续 contract 中记录 operation/diff facts,但不能把 ACP timeout policy 或 Web diff rendering 迁入 core contract crate。 +- 最新主干的 ACP agents config 继续把 remote workspace config reuse 放在 ACP/app surface:remote workspaces 复用 local ACP config,ACP client manager / remote shell / remote capability store / workspace menu 共同决定可用 agent。后续只能抽取 environment/capability facts;ACP config persistence、remote probing 和 workspace surface selection 不进入 core contract crate。 +- 最新主干的 usage/cache token 与 OpenAI Responses 修复要求后续 `agent-stream`、`session_usage`、runtime budget 或 tool schema 迁移保留 provider adapter 语义:`cached_content_token_count` 是 cache reads/hits,`cache_creation_token_count` 与 DeepSeek `prompt_cache_hit_tokens` 不得被合并;Responses / Codex ChatGPT flat tool schema 归 AI adapter serialization,不归 provider-neutral tool manifest contract。 - 最新主干的 Web 启动性能优化新增 startup trace、deferred background scheduler、narrow tool initializer、Monaco warmup 与历史会话非阻塞 hydrate;这些属于 web app / Flow Chat product surface,不是 core service 迁移前置条件。后续只能通过 web product checks 验证,不得把 `startupTrace`、`backgroundTaskScheduler`、history hydration 或 tool warmup 下沉到 core-types / runtime-ports / agent-tools。 - 最新主干的 CLI 重构主要新增 TUI/theme/selector/dialog/chat-state 等 app-layer 代码,后续又收敛预置 theme、增加 mode-aware subagent management,并补充 desktop companion pet resize / Windows UX;这些当前没有改变 `services-integrations` 的迁移归属。后续若调整 shared crate 边界,必须继续把 `bitfun-cli`、`ratatui`、`crossterm`、`arboard`、`syntect-tui` 等 CLI-only 依赖限制在 app adapter / presentation layer,desktop / web-ui presentation 修复也不应被误判为 core service 迁移前置条件。 +- 最新 desktop close button 默认最小化到 system tray 属于 desktop lifecycle surface;后续 desktop app lifecycle / window state 调整只能通过 desktop product check 验证,不作为 core service owner 外移前置条件。 - 最新主干的内置 PR Review MiniApp 通过 core `BuiltinApp` asset bundle、content hash、install marker、customization metadata 与 update marker seed 到用户数据目录;它可以复用 `product-domains` 的 MiniApp 纯 contract,但 builtin asset seeding / customized update runtime 仍显式 core-owned,迁移前必须保留这些行为的等价测试。 - P2 后产品表面策略要求“surface divergence, capability convergence”:CLI `/diff`、Desktop 快捷键/面板、Remote card、ACP method 可以映射到同一 capability contract,但不能为了复用把 surface command 或 UI rendering 下沉到 contract crate。 - `ToolUseContext` 的 shared-context / evidence checkpoint hook、`TaskTool` / `CodeReviewTool` 的 Deep Review capacity flow、session manifest/cache persistence、rollback persisted-turn cleanup、search fallback chain 与 stream finish/tool-call contract 不能在 P3 中只通过 re-export 消失;如果外移,需要先补 boundary contract、旧路径兼容和对应 regression。 @@ -1675,10 +1679,10 @@ P2 后产品表面契约轨道(contract-only): - 当前分支保持单一主题:在 PR2 owner closure 后补关键语义回归 baseline;不移动 runtime owner,不调整产品表面命令/UI,也不改变 CLI、Desktop、Remote、ACP 的运行语义。 - `core-decomposition-implementation-review.md` 的合理建议已纳入当前护栏:ownership target 必须区分 `done` / `partial` / `target` / `deferred`,`bitfun-core/product-full` 目前只是阶段性 capability guardrail,不是最终 feature matrix;boundary script 是必要下限,不能替代行为级回归。 -- 本次 rebase 到最新 `upstream/main` 后,PR #719 remote workspace guard、#721 companion preset、#715/#722 ACP fallback/timeout、per-mode subagent availability、DeepResearch citation renumber hook 和 search fallback/context 修复均已进入主干;它们不改变本轮 guardrail PR 的代码行为,但会把后续 workspace/search、agent registry/runtime、ACP/Web surface 与 tool runtime 外移的等价性门槛抬高。 +- 本次 rebase 到最新 `gcwing/main` 后,PR #719 remote workspace guard、#721 companion preset、#715/#722 ACP fallback/timeout、PR #766 ACP config reuse、PR #774 usage/cache 与 Responses schema 修复、PR #776 desktop close-to-tray 默认值、per-mode subagent availability、DeepResearch citation renumber hook 和 search fallback/context 修复均已进入主干;它们不改变当前文档护栏 PR 的代码行为,但会把后续 workspace/search、agent registry/runtime、ACP/Web surface、AI usage/adapter 与 tool runtime 外移的等价性门槛抬高。 - 质量边界:本阶段证明已拆 owner crate 不依赖回 `bitfun-core`,并新增关键语义 baseline 约束 MCP config failure / catalog replacement invalidation / dynamic manifest、tool manifest / `GetToolSpec` collapsed exposure、MiniApp storage layout adapter 等价和 remote search scan-fallback retry gate;不声明 remote connect、`ToolUseContext`、concrete tool implementation、MiniApp IO / worker runtime 或 function-agent runtime 的外移完成。 - boundary check 已扩展到 `core-types`、`runtime-ports` 和 `agent-tools` 的轻量边界,并覆盖 Cargo inline 依赖和 dependency table 依赖声明,后续不能绕过脚本把重 runtime、concrete service、platform adapter 或 CLI/TUI presentation 依赖带入这些 contract crate。 -- boundary check 现在同时锁定 latest-main owner anchor:mode-scoped subagent availability、`Multitask` / `GeneralPurpose` registration、background subagent delivery、CLI subagent management surface、DeepResearch citation renumber hook、remote workspace startup guard、local/remote search fallback、ACP startup timeout、Web startup/history hydration、Web operation diff fallback 和 built-in MiniApp seed/update path。后续真正迁移这些 owner 时必须先补 port/provider 或 surface contract 设计,并同步更新脚本与等价测试。 +- boundary check 现在锁定已纳入脚本的 latest-main owner anchor:mode-scoped subagent availability、`Multitask` / `GeneralPurpose` registration、background subagent delivery、CLI subagent management surface、DeepResearch citation renumber hook、remote workspace startup guard、local/remote search fallback、ACP startup timeout、Web startup/history hydration、Web operation diff fallback 和 built-in MiniApp seed/update path。2026-05-19 新增识别的 remote ACP config reuse、AI usage/cache semantics、Responses flat tool schema adapter boundary 与 desktop close-to-tray surface 先作为后续迁移的复核清单;真正迁移这些 owner 时必须补 port/provider 或 surface contract 设计,并同步更新脚本与等价测试。 - boundary check 也已锁定 `bitfun-core::service::git`、`bitfun-core::service::remote_ssh::types`、remote-SSH workspace path/identity/unresolved-key helper、MiniApp storage layout、`bitfun-core::service::mcp::{tool_info,tool_name}`、`bitfun-core::service::mcp::protocol::{types,jsonrpc}`、`bitfun-core::service::mcp::config::{location,cursor_format,json_config,service_helpers}`、`bitfun-core::service::mcp::server::config`、`bitfun-core::service::mcp::auth` 和 `bitfun-core::service::announcement::types` 的旧路径 facade-only / 禁止回流状态,并禁止在 `MCPServerProcess` runtime 文件重新定义已外移的 server type/status contract、auth error classifier 和 legacy remote header fallback helper,也禁止在 remote transport 重新实现 Authorization 归一化、client capability 构造和 rmcp result mapping;本轮新增禁止 core registry 重新拥有 `IndexMap` 工具容器或 dynamic metadata map。 - 后续迁移必须拆成可独立审核的提交:先补 port/provider 设计和等价测试;`remote-connect` 完整 runtime、`ToolUseContext` / concrete tool implementation、product-domain runtime 必须一次迁移一个 owner 主题。 - concrete tool implementation 或 product registry / manifest assembly 外移必须先有工具清单和 manifest 等价测试,并保留 dynamic provider metadata;不能把注册名解析、snapshot wrapper 或 runtime restriction 行为改成隐式约定。 @@ -1737,7 +1741,7 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts 15. 已完成:agent tools + `tool-packs` owner 化低风险闭环;tool contract / DTO、runtime restriction、path resolution、portable context facts/provider、generic registry / static provider installation / dynamic provider container 已归属 `bitfun-agent-tools`,`tool-packs` 只提供计划内 feature-group scaffold,core 保留 core-owned product provider groups、snapshot decorator、`ToolUseContext` 和 concrete tool implementation,后续外移需单独 service port/provider 设计。 16. 已完成:关键语义回归 baseline,不移动 runtime owner。覆盖 MCP config failure / catalog invalidation / 既有 list-changed helper / dynamic manifest、tool manifest / `GetToolSpec`、product-domains adapter equivalence、remote workspace search fallback 的 focused tests 或 snapshots。 17. 已完成:remote-connect runtime 当前批次收口。已基于当前 port baseline 记录 remote command/response、remote model catalog、poll response、model catalog delta、session restore、active turn、cancel、image context、tracker event、queue/event fanout 的输入输出和验证命令;tracker state / registry lifecycle、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy 已迁入 `bitfun-services-integrations`。dispatcher / product execution、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行显式保留在 core-owned runtime;后续只有在另起 port/provider 设计且 focused regression 继续通过时才允许继续移动这些 runtime owner,不能把 generic attachment guard 当作已接入多模态行为。 -18. 后续高风险单独审视:`product-domains` runtime + core facade finalization 的剩余 PathManager、process execution、Git/AI service、prompt template、host dispatch 执行与 worker/storage IO owner 迁移;不得与既有 PR2/PR3 范围混合。 +18. 当前 PR:`product-domains` runtime port/facade closure。已迁入 MiniApp storage-backed runtime-state facade 与 function-agent Git/AI port-backed runtime facade,并补充 focused contract tests;core 只对 MiniApp deps/restart/recompile/sync/rollback 的状态持久化委托 facade,仍保留 `PathManager` 注入、filesystem IO、worker process execution、host dispatch 执行、built-in asset seeding/source-hash lookup、Git/AI service 调用、prompt template、JSON extraction 和 error mapping adapter。function-agent facade 与 core AI adapter 只作为后续接线入口,现有产品路径暂不切换。 19. 后续独立评估:`bitfun-core default = []`、per-product feature set、依赖版本收敛或构建收益优化;任何收益声明都需要记录 `cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据。 冗余清理 PR 不进入上述主线序号。只有在满足 `0A.6` 的绝对等价要求时,才可以插入到相邻里程碑之间,并且不得与主线拆分 PR 混合。 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index d4a04ecbf..92545e52a 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -2113,7 +2113,7 @@ const requiredContentRules = [ { path: 'src/crates/core/src/miniapp/manager.rs', reason: - 'core MiniApp manager must use product-domain pure policy helpers while retaining compile, storage IO, and built-in source-hash lookup', + 'core MiniApp manager must use product-domain policy/facade helpers while retaining compile, storage IO, and built-in source-hash lookup', patterns: [ { regex: /\bapply_draft_customization_metadata\b/, @@ -2128,12 +2128,16 @@ const requiredContentRules = [ message: 'missing product-domain built-in update decline helper use', }, { - regex: /\bmark_deps_installed_state\b/, - message: 'missing product-domain MiniApp deps-installed state helper use', + regex: /\bMiniAppRuntimeFacade\b/, + message: 'missing product-domain MiniApp runtime-state facade use', + }, + { + regex: /\bpersist_sync_from_fs_result_for_app\b/, + message: 'missing product-domain MiniApp sync-from-fs facade delegation', }, { - regex: /\bapply_sync_from_fs_result\b/, - message: 'missing product-domain MiniApp sync-from-fs state helper use', + regex: /\bcompile_source\b/, + message: 'missing core-owned MiniApp compile orchestration', }, { regex: /\bREQUIRED_SOURCE_FILES\b/, @@ -2165,6 +2169,25 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/product-domains/src/miniapp/ports.rs', + reason: + 'product-domains owns MiniApp runtime-state port facade while core keeps concrete storage IO, compile, worker, and host execution', + patterns: [ + { + regex: /\bpub struct MiniAppRuntimeFacade\b/, + message: 'missing MiniApp runtime-state facade', + }, + { + regex: /\bmark_deps_installed_state\b/, + message: 'missing MiniApp deps-installed state transition in facade', + }, + { + regex: /\bpersist_sync_from_fs_result_for_app\b/, + message: 'missing MiniApp sync-from-fs preloaded snapshot facade path', + }, + ], + }, { path: 'src/crates/core/src/function_agents/git-func-agent/ai_service.rs', reason: @@ -2196,6 +2219,29 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/product-domains/src/function_agents/ports.rs', + reason: + 'product-domains owns port-backed function-agent facade orchestration while core keeps concrete Git/AI runtime calls', + patterns: [ + { + regex: /\bpub struct FunctionAgentRuntimeFacade\b/, + message: 'missing function-agent runtime facade', + }, + { + regex: /\bgenerate_commit_message\b/, + message: 'missing function-agent commit facade orchestration', + }, + { + regex: /\banalyze_work_state\b/, + message: 'missing function-agent work-state facade orchestration', + }, + { + regex: /\bgit_work_state_from_snapshot\b/, + message: 'missing Startchat Git snapshot projection helper', + }, + ], + }, { path: 'src/crates/product-domains/src/function_agents/startchat_func_agent/utils.rs', reason: @@ -2267,7 +2313,7 @@ const requiredContentRules = [ { path: 'src/crates/core/src/function_agents/port_adapters.rs', reason: - 'core must continue owning function-agent Git runtime adapter until Git/AI service migration is reviewed', + 'core must continue owning function-agent Git/AI runtime adapters until Git/AI service migration is reviewed', patterns: [ { regex: /\bpub struct CoreFunctionAgentGitAdapter\b/, @@ -2277,6 +2323,14 @@ const requiredContentRules = [ regex: /\bimpl FunctionAgentGitPort for CoreFunctionAgentGitAdapter\b/, message: 'missing function-agent Git port adapter owner', }, + { + regex: /\bpub struct CoreFunctionAgentAiAdapter\b/, + message: 'missing core function-agent AI adapter type', + }, + { + regex: /\bimpl FunctionAgentAiPort for CoreFunctionAgentAiAdapter\b/, + message: 'missing function-agent AI port adapter owner', + }, { regex: /\bgit_adapter_commit_snapshot_keeps_staged_diff_and_unstaged_count_separate\b/, message: 'missing function-agent Git snapshot boundary regression test', @@ -2968,6 +3022,8 @@ function runManifestParserSelfTest() { contracts: [ 'CoreFunctionAgentGitAdapter', 'FunctionAgentGitPort', + 'CoreFunctionAgentAiAdapter', + 'FunctionAgentAiPort', 'git_adapter_commit_snapshot_keeps_staged_diff_and_unstaged_count_separate', ], }, @@ -2982,6 +3038,14 @@ function runManifestParserSelfTest() { 'unresolved_remote_session_storage_dir', ], }, + { + path: 'src/crates/product-domains/src/miniapp/ports.rs', + contracts: [ + 'MiniAppRuntimeFacade', + 'mark_deps_installed_state', + 'persist_sync_from_fs_result_for_app', + ], + }, { path: 'src/crates/product-domains/src/miniapp/storage.rs', contracts: [ @@ -3045,8 +3109,9 @@ function runManifestParserSelfTest() { 'mark_builtin_update_available_metadata', 'decline_builtin_update_metadata', 'storage.load_customization_metadata', - 'mark_deps_installed_state', - 'apply_sync_from_fs_result', + 'MiniAppRuntimeFacade', + 'persist_sync_from_fs_result_for_app', + 'compile_source', 'REQUIRED_SOURCE_FILES', 'MiniAppImportLayout', 'build_import_fallbacks', @@ -3066,6 +3131,15 @@ function runManifestParserSelfTest() { 'parse_commit_response_preserves_core_json_extraction_and_error_mapping', ], }, + { + path: 'src/crates/product-domains/src/function_agents/ports.rs', + contracts: [ + 'FunctionAgentRuntimeFacade', + 'generate_commit_message', + 'analyze_work_state', + 'git_work_state_from_snapshot', + ], + }, { path: 'src/crates/product-domains/src/function_agents/startchat_func_agent/utils.rs', contracts: ['ParsedCompleteAnalysis', 'parse_complete_analysis_value'], diff --git a/src/apps/desktop/AGENTS-CN.md b/src/apps/desktop/AGENTS-CN.md index 239ee278a..12685a96d 100644 --- a/src/apps/desktop/AGENTS-CN.md +++ b/src/apps/desktop/AGENTS-CN.md @@ -21,6 +21,7 @@ ## 本模块规则 - 桌面端专属集成留在这里,不要下沉到共享 core +- 窗口 lifecycle 行为(包括 close/minimize-to-tray 默认值)属于桌面端 surface;修改时必须保留用户已保存偏好。 - 涉及打包或 release 请求时,参见顶层 `AGENTS.md` ## 命令 diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index db24f9b10..f8c439b13 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -21,6 +21,8 @@ If a change affects shared product behavior across runtimes, the implementation ## Local rules - Keep desktop-only integrations here; do not move them into shared core +- Window lifecycle behavior, including close/minimize-to-tray defaults, is a + desktop surface concern. Preserve saved user preferences when changing it. - For packaging or release asks, see the top-level `AGENTS.md` ## Commands diff --git a/src/crates/acp/AGENTS-CN.md b/src/crates/acp/AGENTS-CN.md new file mode 100644 index 000000000..7231b1b50 --- /dev/null +++ b/src/crates/acp/AGENTS-CN.md @@ -0,0 +1,24 @@ +**中文** | [English](AGENTS.md) + +# ACP Agent 指南 + +适用范围:`src/crates/acp`。 + +`bitfun-acp` 负责 Agent Client Protocol 集成和 ACP client 行为。ACP protocol / +client 细节应留在这里或 app surface adapter;contract crate 只共享稳定 capability facts。 + +## 护栏 + +- Remote ACP workspace 复用本地 ACP client config。修改 ACP client 行为时,必须保留 + manager、remote shell probing、remote capability store 与 workspace menu availability 语义。 +- ACP config persistence、remote probing、timeout policy 和 workspace surface selection + 属于 ACP / app surface 行为,不要下沉到 `core-types`、`runtime-ports` 或 `agent-tools`。 +- 如果后续需要 contract,只记录 observational 信息:environment identity、capability facts + 与 request/response DTO。 + +## 验证 + +```bash +cargo check -p bitfun-acp +cargo test -p bitfun-acp +``` diff --git a/src/crates/acp/AGENTS.md b/src/crates/acp/AGENTS.md new file mode 100644 index 000000000..2b67a1713 --- /dev/null +++ b/src/crates/acp/AGENTS.md @@ -0,0 +1,27 @@ +[中文](AGENTS-CN.md) | **English** + +# ACP Agent Guide + +Scope: this guide applies to `src/crates/acp`. + +`bitfun-acp` owns Agent Client Protocol integration and ACP client behavior. +Keep ACP protocol/client details here or in app-surface adapters; share only +stable capability facts through contract crates. + +## Guardrails + +- Remote ACP workspaces reuse local ACP client configuration. Preserve the + manager, remote shell probing, remote capability store, and workspace menu + availability semantics when changing ACP client behavior. +- ACP config persistence, remote probing, timeout policy, and workspace surface + selection are ACP/app-surface behavior. Do not move them into `core-types`, + `runtime-ports`, or `agent-tools`. +- If a future contract is needed, make it observational: environment identity, + capability facts, and request/response DTOs only. + +## Verification + +```bash +cargo check -p bitfun-acp +cargo test -p bitfun-acp +``` diff --git a/src/crates/ai-adapters/AGENTS.md b/src/crates/ai-adapters/AGENTS.md index de68f2c6f..dbe651e37 100644 --- a/src/crates/ai-adapters/AGENTS.md +++ b/src/crates/ai-adapters/AGENTS.md @@ -1 +1,28 @@ -If you modify this crate, run the stream integration tests in `src/crates/core/tests` before finishing. +# AI Adapters Agent Guide + +Scope: this guide applies to `src/crates/ai-adapters`. + +`bitfun-ai-adapters` owns provider-specific request/response mapping and stream +normalization. Keep provider quirks here instead of leaking them into core tool +contracts or product runtime logic. + +## Guardrails + +- OpenAI Responses and Codex ChatGPT flat tool schemas are adapter + serialization behavior. Keep core/tool manifests provider-neutral. +- `cached_content_token_count` means cache reads/hits. Keep + `cache_creation_token_count` separate, and preserve provider-specific mappings + such as DeepSeek prompt-cache hits and Gemini's current lack of creation + count. +- Do not change shared stream or usage semantics without updating the focused + adapter tests and downstream usage expectations. + +## Verification + +```bash +cargo test -p bitfun-agent-stream +cargo test -p bitfun-ai-adapters +``` + +If stream behavior affects core integration, also run the relevant tests in +`src/crates/core/tests`. diff --git a/src/crates/core/AGENTS-CN.md b/src/crates/core/AGENTS-CN.md index 5f1897d03..e54ac7a7f 100644 --- a/src/crates/core/AGENTS-CN.md +++ b/src/crates/core/AGENTS-CN.md @@ -29,9 +29,11 @@ SessionManager → Session → DialogTurn → ModelRound - 使用 `bitfun_events::EventEmitter` 等共享抽象 - 桌面端专属集成应放在 `src/apps/desktop`,再通过 transport / API layer 连接回来 - core 拆解期间,`bitfun-core` 是兼容 facade 与完整产品 runtime assembly 点;新模块优先放到 `docs/architecture/core-decomposition.md` 指定的 owner crate。 -- Tool 相关轻量 contract 与 generic registry/provider container 归属 `bitfun-agent-tools`;core tool runtime 当前负责产品工具组装、`dyn Tool` 适配、snapshot decoration、tool exposure / manifest resolution,以及按需工具说明发现(`GetToolSpec`)。 +- Tool 相关轻量 contract、portable tool context facts/provider、纯 manifest/exposure contract 与 generic registry / static-provider / dynamic-provider container 归属 `bitfun-agent-tools`;core tool runtime 当前负责产品工具组装、`dyn Tool` 适配、snapshot decoration、runtime manifest assembly / context filtering,以及按需工具说明发现(`GetToolSpec`)执行。 - `ToolUseContext` 与具体工具实现继续留在 core,除非已有评审过的 port/provider 方案和等价测试。 - Tool 迁移必须保持 expanded/collapsed exposure、prompt 可见 manifest、`ToolUseContext.unlocked_collapsed_tools`,以及 desktop/MCP/ACP tool catalog 行为等价。 +- 不要把 OpenAI Responses / Codex ChatGPT flat tool schema 等 provider-specific 序列化行为写进 core tool contract;AI adapter 负责 provider 序列化,core 保持 provider-neutral manifest。 +- 调整 session/token usage 路径时,`cached_content_token_count` 必须继续表示 cache reads/hits,`cache_creation_token_count` 必须作为独立 provider fact 保留。 - 不要在没有小型 port/interface 边界的情况下新增 `service` 到 `agentic` 的跨层引用。 - 不要在 core 拆解中把平台专属逻辑、构建脚本行为或产品能力选择下沉到 shared core。 diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index f2826b2e7..dfac74afd 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -46,6 +46,12 @@ SessionManager → Session → DialogTurn → ModelRound - Any tool migration must preserve expanded/collapsed exposure, prompt-visible manifests, `ToolUseContext.unlocked_collapsed_tools`, and desktop/MCP/ACP tool catalog behavior. +- Do not encode provider-specific OpenAI Responses / Codex ChatGPT flat tool + schema behavior in core tool contracts; AI adapters own provider + serialization while core keeps provider-neutral manifests. +- When touching session/token usage paths, keep `cached_content_token_count` + as cache reads/hits and `cache_creation_token_count` as a separate provider + fact. - Do not add new cross-layer references from `service` to `agentic` without a small port/interface boundary. - Do not move platform-specific logic, build-script behavior, or product diff --git a/src/crates/core/src/function_agents/port_adapters.rs b/src/crates/core/src/function_agents/port_adapters.rs index 3c8153627..af39b09f8 100644 --- a/src/crates/core/src/function_agents/port_adapters.rs +++ b/src/crates/core/src/function_agents/port_adapters.rs @@ -1,14 +1,20 @@ //! Core adapters for product-domain function-agent ports. use std::path::{Path, PathBuf}; +use std::sync::Arc; use bitfun_product_domains::function_agents::ports::{ - FunctionAgentFuture, FunctionAgentGitPort, GitCommitSnapshot, StartchatGitSnapshot, + CommitAiAnalysisRequest, FunctionAgentAiPort, FunctionAgentFuture, FunctionAgentGitPort, + GitCommitSnapshot, StartchatGitSnapshot, WorkStateAiAnalysisRequest, }; use bitfun_product_domains::function_agents::startchat_func_agent::AheadBehind; +use bitfun_product_domains::function_agents::{ + git_func_agent::AICommitAnalysis, startchat_func_agent::AIGeneratedAnalysis, +}; use crate::function_agents::common::{AgentError, AgentResult}; use crate::function_agents::git_func_agent::ContextAnalyzer; +use crate::infrastructure::ai::AIClientFactory; use crate::service::git::{GitDiffParams, GitService}; #[derive(Debug, Default, Clone)] @@ -79,6 +85,62 @@ impl CoreFunctionAgentGitAdapter { } } +#[derive(Clone)] +pub struct CoreFunctionAgentAiAdapter { + factory: Arc, +} + +impl CoreFunctionAgentAiAdapter { + pub fn new(factory: Arc) -> Self { + Self { factory } + } +} + +impl FunctionAgentAiPort for CoreFunctionAgentAiAdapter { + fn analyze_commit( + &self, + request: CommitAiAnalysisRequest, + ) -> FunctionAgentFuture<'_, AICommitAnalysis> { + let factory = self.factory.clone(); + Box::pin(async move { + let service = + crate::function_agents::git_func_agent::AIAnalysisService::new_with_agent_config( + factory, + "git-func-agent", + ) + .await?; + service + .generate_commit_message_ai( + &request.diff_content, + &request.project_context, + &request.options, + ) + .await + }) + } + + fn analyze_work_state( + &self, + request: WorkStateAiAnalysisRequest, + ) -> FunctionAgentFuture<'_, AIGeneratedAnalysis> { + let factory = self.factory.clone(); + Box::pin(async move { + let service = crate::function_agents::startchat_func_agent::AIWorkStateService::new_with_agent_config( + factory, + "startchat-func-agent", + ) + .await?; + service + .generate_complete_analysis( + &request.git_state, + &request.git_diff, + &request.language, + ) + .await + }) + } +} + fn git_stdout(repo_path: &Path, args: &[&str]) -> AgentResult { let output = crate::util::process_manager::create_command("git") .args(args) diff --git a/src/crates/core/src/miniapp/manager.rs b/src/crates/core/src/miniapp/manager.rs index ee60fedf0..d63ac48af 100644 --- a/src/crates/core/src/miniapp/manager.rs +++ b/src/crates/core/src/miniapp/manager.rs @@ -8,23 +8,25 @@ use crate::miniapp::types::{ }; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_product_domains::miniapp::customization::{ - MiniAppCustomizationBaseline, MiniAppCustomizationLocalSnapshot, MiniAppCustomizationMetadata, - MiniAppPermissionDiff, apply_draft_customization_metadata, decline_builtin_update_metadata, + apply_draft_customization_metadata, decline_builtin_update_metadata, declined_builtin_update_needs_local_snapshot, diff_permissions, is_current_declined_builtin_update, mark_builtin_update_available_metadata, + MiniAppCustomizationBaseline, MiniAppCustomizationLocalSnapshot, MiniAppCustomizationMetadata, + MiniAppPermissionDiff, }; use bitfun_product_domains::miniapp::draft::{ - MiniAppDraft, MiniAppDraftManifest, build_draft_manifest, build_draft_response, + build_draft_manifest, build_draft_response, MiniAppDraft, MiniAppDraftManifest, }; use bitfun_product_domains::miniapp::lifecycle::{ - apply_import_runtime_state, apply_recompile_result, apply_sync_from_fs_result, - build_deps_revision, build_runtime_state, build_source_revision, build_worker_revision, - clear_worker_restart_required_state, ensure_runtime_state, mark_deps_installed_state, - prepare_rollback_app, workspace_dir_string, + apply_import_runtime_state, build_deps_revision, build_runtime_state, build_source_revision, + build_worker_revision, ensure_runtime_state, workspace_dir_string, +}; +use bitfun_product_domains::miniapp::ports::{ + MiniAppPortError, MiniAppPortErrorKind, MiniAppRuntimeFacade, }; use bitfun_product_domains::miniapp::storage::{ - COMPILED_HTML, ESM_DEPS_JSON, META_JSON, MiniAppImportLayout, PACKAGE_JSON, - REQUIRED_SOURCE_FILES, SOURCE_DIR, STORAGE_JSON, build_import_fallbacks, + build_import_fallbacks, MiniAppImportLayout, COMPILED_HTML, ESM_DEPS_JSON, META_JSON, + PACKAGE_JSON, REQUIRED_SOURCE_FILES, SOURCE_DIR, STORAGE_JSON, }; use chrono::Utc; use std::collections::HashMap; @@ -67,6 +69,10 @@ impl MiniAppManager { build_worker_revision(app, policy_json) } + fn runtime_facade(&self) -> MiniAppRuntimeFacade<'_> { + MiniAppRuntimeFacade::new(&self.storage) + } + pub fn compile_source( &self, app_id: &str, @@ -670,18 +676,17 @@ impl MiniAppManager { } pub async fn mark_deps_installed(&self, app_id: &str) -> BitFunResult { - let mut app = self.storage.load(app_id).await?; - mark_deps_installed_state(&mut app); - self.storage.save(&app).await?; - Ok(app) + self.runtime_facade() + .mark_deps_installed(app_id.to_string()) + .await + .map_err(map_miniapp_port_error) } pub async fn clear_worker_restart_required(&self, app_id: &str) -> BitFunResult { - let mut app = self.storage.load(app_id).await?; - if clear_worker_restart_required_state(&mut app) { - self.storage.save(&app).await?; - } - Ok(app) + self.runtime_facade() + .clear_worker_restart_required(app_id.to_string()) + .await + .map_err(map_miniapp_port_error) } /// List version numbers for an app. @@ -691,15 +696,11 @@ impl MiniAppManager { /// Rollback app to a previous version (loads version snapshot, saves as current). pub async fn rollback(&self, app_id: &str, version: u32) -> BitFunResult { - let current = self.storage.load(app_id).await?; - let app = self.storage.load_version(app_id, version).await?; let now = Utc::now().timestamp_millis(); - let app = prepare_rollback_app(¤t, app, now); - self.storage - .save_version(app_id, current.version, ¤t) - .await?; - self.storage.save(&app).await?; - Ok(app) + self.runtime_facade() + .rollback(app_id.to_string(), version, now) + .await + .map_err(map_miniapp_port_error) } /// Recompile app (e.g. after workspace or theme change). Updates compiled_html and saves. @@ -709,12 +710,13 @@ impl MiniAppManager { theme: &str, workspace_root: Option<&Path>, ) -> BitFunResult { - let mut app = self.storage.load(app_id).await?; + let app = self.storage.load(app_id).await?; let compiled_html = self.compile_source(app_id, &app.source, &app.permissions, theme, workspace_root)?; - apply_recompile_result(&mut app, compiled_html, Utc::now().timestamp_millis()); - self.storage.save(&app).await?; - Ok(app) + self.runtime_facade() + .persist_recompile_result_for_app(app, compiled_html, Utc::now().timestamp_millis()) + .await + .map_err(map_miniapp_port_error) } pub async fn sync_from_fs( @@ -732,17 +734,16 @@ impl MiniAppManager { theme, workspace_root, )?; - let app = apply_sync_from_fs_result( - &previous_app, - source, - compiled_html, - Utc::now().timestamp_millis(), - ); - self.storage - .save_version(app_id, previous_app.version, &previous_app) - .await?; - self.storage.save(&app).await?; - Ok(app) + self.runtime_facade() + .persist_sync_from_fs_result_for_app( + app_id.to_string(), + previous_app, + source, + compiled_html, + Utc::now().timestamp_millis(), + ) + .await + .map_err(map_miniapp_port_error) } /// Import a MiniApp from a directory (e.g. miniapps/git-graph). Copies meta, source, package.json, storage into a new app id and recompiles. @@ -871,6 +872,39 @@ impl MiniAppManager { } } +fn map_miniapp_port_error(error: MiniAppPortError) -> BitFunError { + let message = strip_bitfun_error_prefix(error.message); + match error.kind { + MiniAppPortErrorKind::NotFound => BitFunError::NotFound(message), + MiniAppPortErrorKind::InvalidInput => BitFunError::validation(message), + MiniAppPortErrorKind::PermissionDenied => BitFunError::Io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + message, + )), + MiniAppPortErrorKind::RuntimeUnavailable => BitFunError::ProcessError(message), + MiniAppPortErrorKind::Io => BitFunError::io(message), + MiniAppPortErrorKind::Backend => BitFunError::service(message), + } +} + +fn strip_bitfun_error_prefix(message: String) -> String { + const PREFIXES: &[&str] = &[ + "Not found: ", + "Validation error: ", + "Deserialization error: ", + "IO error: ", + "Process error: ", + "Service error: ", + ]; + + for prefix in PREFIXES { + if let Some(stripped) = message.strip_prefix(prefix) { + return stripped.to_string(); + } + } + message +} + #[cfg(test)] mod tests { use super::*; @@ -921,6 +955,30 @@ mod tests { .unwrap() } + #[test] + fn miniapp_port_error_mapping_preserves_manager_error_shape() { + let not_found = map_miniapp_port_error(MiniAppPortError::new( + MiniAppPortErrorKind::NotFound, + "Not found: MiniApp not found: missing", + )); + assert_eq!( + not_found.to_string(), + "Not found: MiniApp not found: missing" + ); + + let permission_denied = map_miniapp_port_error(MiniAppPortError::new( + MiniAppPortErrorKind::PermissionDenied, + "IO error: access denied", + )); + match permission_denied { + BitFunError::Io(error) => { + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!(error.to_string(), "access denied"); + } + other => panic!("expected permission denied IO error, got {other:?}"), + } + } + async fn write_import_source(root: &std::path::Path) { let source_dir = root.join(SOURCE_DIR); tokio::fs::create_dir_all(&source_dir).await.unwrap(); @@ -1060,12 +1118,10 @@ mod tests { .unwrap(); assert_eq!(package_json["name"], format!("miniapp-{}", imported.id)); assert_eq!(package_json["dependencies"], serde_json::json!({})); - assert!( - tokio::fs::read_to_string(app_dir.join(COMPILED_HTML)) - .await - .unwrap() - .contains("textContent = 'imported'") - ); + assert!(tokio::fs::read_to_string(app_dir.join(COMPILED_HTML)) + .await + .unwrap() + .contains("textContent = 'imported'")); let _ = tokio::fs::remove_dir_all(import_root).await; } diff --git a/src/crates/product-domains/AGENTS-CN.md b/src/crates/product-domains/AGENTS-CN.md index fa652d413..cfd91ca1c 100644 --- a/src/crates/product-domains/AGENTS-CN.md +++ b/src/crates/product-domains/AGENTS-CN.md @@ -28,6 +28,21 @@ `function-agents` 只放 function-agent 专属依赖,`product-full` 只聚合已有 产品领域 feature 组。 +## 当前归属 + +- `miniapp` 拥有 MiniApp DTO、compiler/bridge helper、storage/draft/import + 文件形态、fallback payload、runtime search plan、worker install 命令选择、 + lifecycle/revision 与 manager state-transition helper、host-routing string + policy、customization metadata policy、port trait,以及 storage-backed runtime + state facade。 +- `function-agents` 拥有纯 DTO、prompt assembly、commit prompt preparation、 + AI response parsing policy、diff truncation policy、本地文件形态分析、 + Git/AI port trait,以及 port-backed runtime facade orchestration。 +- Core 仍拥有 MiniApp filesystem IO、worker process、host dispatch、built-in + asset seeding/source-hash lookup、`PathManager` 集成、function-agent Git/AI + 调用、prompt template、JSON extraction、error mapping,以及尚未被等价测试覆盖的 + 产品调用路径切换。 + ## 验证 按改动范围选择最小验证: diff --git a/src/crates/product-domains/AGENTS.md b/src/crates/product-domains/AGENTS.md index 9dfe57096..d93cad993 100644 --- a/src/crates/product-domains/AGENTS.md +++ b/src/crates/product-domains/AGENTS.md @@ -37,14 +37,17 @@ moves here gradually. file shapes, import fallback payloads, runtime search-plan helpers, worker install command selection, lifecycle/revision and manager state-transition helpers, host-routing string policy, customization metadata policy including - built-in update/decline decisions, and port traits. + built-in update/decline decisions, port traits, and storage-backed runtime + state facade logic. - `function-agents` owns pure function-agent DTOs, prompt assembly helpers, commit prompt preparation, AI-response parsing policy, diff truncation policy, - local file-shape analysis, and Git/AI port traits. + local file-shape analysis, Git/AI port traits, and port-backed runtime facade + orchestration. - Core still owns MiniApp filesystem IO, worker process execution, host dispatch execution, built-in asset seeding/source-hash lookup, `PathManager` integration, function-agent Git/AI calls, prompt templates, JSON extraction, - and error mapping. + error mapping, and any product call-path rewiring not covered by equivalence + tests. ## Verification diff --git a/src/crates/product-domains/src/function_agents/ports.rs b/src/crates/product-domains/src/function_agents/ports.rs index e82374a91..75e24be87 100644 --- a/src/crates/product-domains/src/function_agents/ports.rs +++ b/src/crates/product-domains/src/function_agents/ports.rs @@ -4,12 +4,15 @@ //! templates, JSON extraction, and error mapping. These ports define the seam //! that future adapters must satisfy before those implementations move. -use crate::function_agents::common::{AgentResult, Language}; +use crate::function_agents::common::{AgentError, AgentResult, Language}; use crate::function_agents::git_func_agent::{ - AICommitAnalysis, CommitMessageOptions, ProjectContext, + assemble_commit_message, build_changes_summary_from_paths, AICommitAnalysis, CommitMessage, + CommitMessageOptions, ProjectContext, }; use crate::function_agents::startchat_func_agent::{ - AIGeneratedAnalysis, AheadBehind, GitWorkState, + combine_git_diffs, parse_git_status_porcelain, time_of_day_for_hour, AIGeneratedAnalysis, + AheadBehind, CurrentWorkState, GitWorkState, GreetingMessage, TimeInfo, WorkStateAnalysis, + WorkStateOptions, }; use serde::{Deserialize, Serialize}; use std::future::Future; @@ -78,3 +81,159 @@ pub trait FunctionAgentAiPort: Send + Sync { request: WorkStateAiAnalysisRequest, ) -> FunctionAgentFuture<'_, AIGeneratedAnalysis>; } + +/// Port-backed function-agent facade for future runtime owner migration. +/// +/// It owns only pure orchestration over function-agent ports and DTO helpers. +/// Core still owns Git/AI service calls, prompt templates, JSON extraction, +/// and concrete error mapping until the existing runtime path is explicitly +/// rewired with equivalence tests. +pub struct FunctionAgentRuntimeFacade<'a> { + git: &'a dyn FunctionAgentGitPort, + ai: &'a dyn FunctionAgentAiPort, +} + +impl<'a> FunctionAgentRuntimeFacade<'a> { + pub fn new(git: &'a dyn FunctionAgentGitPort, ai: &'a dyn FunctionAgentAiPort) -> Self { + Self { git, ai } + } + + pub async fn generate_commit_message( + &self, + repo_path: String, + options: CommitMessageOptions, + ) -> AgentResult { + let snapshot = self.git.git_commit_snapshot(repo_path).await?; + if snapshot.staged_paths.is_empty() { + return Err(AgentError::invalid_input( + "Staging area is empty, please stage files first", + )); + } + if snapshot.diff_content.trim().is_empty() { + return Err(AgentError::invalid_input("Diff content is empty")); + } + + let ai_analysis = self + .ai + .analyze_commit(CommitAiAnalysisRequest { + diff_content: snapshot.diff_content, + project_context: snapshot.project_context, + options, + }) + .await?; + + let changes_summary = build_changes_summary_from_paths( + &snapshot.staged_paths, + snapshot.staged_count, + snapshot.unstaged_count, + ); + let full_message = assemble_commit_message( + &ai_analysis.title, + &ai_analysis.body, + &ai_analysis.breaking_changes, + ); + + Ok(CommitMessage { + title: ai_analysis.title, + body: ai_analysis.body, + footer: ai_analysis.breaking_changes, + full_message, + commit_type: ai_analysis.commit_type, + scope: ai_analysis.scope, + confidence: ai_analysis.confidence, + changes_summary, + }) + } + + pub async fn analyze_work_state( + &self, + repo_path: String, + options: WorkStateOptions, + now_timestamp: i64, + current_hour: u32, + analyzed_at: String, + ) -> AgentResult { + let snapshot = if options.analyze_git { + self.git.startchat_git_snapshot(repo_path).await.ok() + } else { + None + }; + let git_state = snapshot.as_ref().map(git_work_state_from_snapshot); + let git_diff = if git_state + .as_ref() + .is_some_and(|state| state.unstaged_files > 0 || state.staged_files > 0) + { + snapshot + .as_ref() + .map(|snapshot| combine_git_diffs(&snapshot.unstaged_diff, &snapshot.staged_diff)) + .unwrap_or_default() + } else { + String::new() + }; + let time_info = time_info_from_snapshot(snapshot.as_ref(), now_timestamp, current_hour); + + let ai_analysis = self + .ai + .analyze_work_state(WorkStateAiAnalysisRequest { + git_state: git_state.clone(), + git_diff, + language: options.language.clone(), + }) + .await?; + + Ok(WorkStateAnalysis { + greeting: GreetingMessage { + title: String::new(), + subtitle: String::new(), + tagline: None, + }, + current_state: CurrentWorkState { + summary: ai_analysis.summary, + git_state, + ongoing_work: ai_analysis.ongoing_work, + time_info, + }, + predicted_actions: if options.predict_next_actions { + ai_analysis.predicted_actions + } else { + Vec::new() + }, + quick_actions: if options.include_quick_actions { + ai_analysis.quick_actions + } else { + Vec::new() + }, + analyzed_at, + }) + } +} + +pub fn git_work_state_from_snapshot(snapshot: &StartchatGitSnapshot) -> GitWorkState { + let (unstaged_files, staged_files, modified_files) = + parse_git_status_porcelain(&snapshot.status_porcelain); + GitWorkState { + current_branch: snapshot.current_branch.clone(), + unstaged_files, + staged_files, + unpushed_commits: snapshot.unpushed_commits, + ahead_behind: snapshot.ahead_behind.clone(), + modified_files, + } +} + +pub fn time_info_from_snapshot( + snapshot: Option<&StartchatGitSnapshot>, + now_timestamp: i64, + current_hour: u32, +) -> TimeInfo { + let minutes_since_last_commit = snapshot + .and_then(|snapshot| snapshot.last_commit_timestamp) + .map(|timestamp| (now_timestamp - timestamp) / 60) + .map(|minutes| minutes as u64); + + TimeInfo { + minutes_since_last_commit, + last_commit_time_desc: None, + time_of_day: time_of_day_for_hour(current_hour), + } +} diff --git a/src/crates/product-domains/src/miniapp/ports.rs b/src/crates/product-domains/src/miniapp/ports.rs index 4b9333b76..0cd3fbb47 100644 --- a/src/crates/product-domains/src/miniapp/ports.rs +++ b/src/crates/product-domains/src/miniapp/ports.rs @@ -4,6 +4,10 @@ //! implementations. Core keeps the current PathManager, process, and storage //! execution until equivalence tests cover a concrete adapter. +use crate::miniapp::lifecycle::{ + apply_recompile_result, apply_sync_from_fs_result, clear_worker_restart_required_state, + mark_deps_installed_state, prepare_rollback_app, +}; use crate::miniapp::runtime::DetectedRuntime; use crate::miniapp::types::{MiniApp, MiniAppMeta, MiniAppSource, NpmDep}; use crate::miniapp::worker::InstallResult; @@ -84,3 +88,102 @@ pub trait MiniAppRuntimePort: Send + Sync { request: MiniAppInstallDepsRequest, ) -> MiniAppPortFuture<'_, InstallResult>; } + +/// Storage-backed facade for MiniApp runtime-state lifecycle transitions. +/// +/// This keeps only portable state persistence in product-domains. Core still +/// owns compilation, filesystem reads, worker processes, host dispatch, and +/// built-in app runtime policy. +pub struct MiniAppRuntimeFacade<'a> { + storage: &'a dyn MiniAppStoragePort, +} + +impl<'a> MiniAppRuntimeFacade<'a> { + pub fn new(storage: &'a dyn MiniAppStoragePort) -> Self { + Self { storage } + } + + pub async fn mark_deps_installed(&self, app_id: String) -> MiniAppPortResult { + let mut app = self.storage.load(app_id).await?; + mark_deps_installed_state(&mut app); + self.storage.save(app.clone()).await?; + Ok(app) + } + + pub async fn clear_worker_restart_required( + &self, + app_id: String, + ) -> MiniAppPortResult { + let mut app = self.storage.load(app_id).await?; + if clear_worker_restart_required_state(&mut app) { + self.storage.save(app.clone()).await?; + } + Ok(app) + } + + pub async fn rollback( + &self, + app_id: String, + version: u32, + now: i64, + ) -> MiniAppPortResult { + let current = self.storage.load(app_id.clone()).await?; + let target = self.storage.load_version(app_id.clone(), version).await?; + let app = prepare_rollback_app(¤t, target, now); + self.storage + .save_version(app_id, current.version, current) + .await?; + self.storage.save(app.clone()).await?; + Ok(app) + } + + pub async fn persist_recompile_result( + &self, + app_id: String, + compiled_html: String, + now: i64, + ) -> MiniAppPortResult { + let app = self.storage.load(app_id).await?; + self.persist_recompile_result_for_app(app, compiled_html, now) + .await + } + + pub async fn persist_recompile_result_for_app( + &self, + mut app: MiniApp, + compiled_html: String, + now: i64, + ) -> MiniAppPortResult { + apply_recompile_result(&mut app, compiled_html, now); + self.storage.save(app.clone()).await?; + Ok(app) + } + + pub async fn persist_sync_from_fs_result( + &self, + app_id: String, + source: MiniAppSource, + compiled_html: String, + now: i64, + ) -> MiniAppPortResult { + let previous = self.storage.load(app_id.clone()).await?; + self.persist_sync_from_fs_result_for_app(app_id, previous, source, compiled_html, now) + .await + } + + pub async fn persist_sync_from_fs_result_for_app( + &self, + app_id: String, + previous: MiniApp, + source: MiniAppSource, + compiled_html: String, + now: i64, + ) -> MiniAppPortResult { + let app = apply_sync_from_fs_result(&previous, source, compiled_html, now); + self.storage + .save_version(app_id, previous.version, previous) + .await?; + self.storage.save(app.clone()).await?; + Ok(app) + } +} diff --git a/src/crates/product-domains/tests/function_agent_contracts.rs b/src/crates/product-domains/tests/function_agent_contracts.rs index 9363b99a8..57eae6807 100644 --- a/src/crates/product-domains/tests/function_agent_contracts.rs +++ b/src/crates/product-domains/tests/function_agent_contracts.rs @@ -1,25 +1,29 @@ #![cfg(feature = "function-agents")] use bitfun_product_domains::function_agents::{ - Language, git_func_agent::{ + assemble_commit_message, build_changes_summary_from_paths, build_commit_prompt, + detect_change_patterns, extract_module_name, infer_file_type, parse_commit_analysis_value, + parse_commit_type_label, prepare_commit_prompt, truncate_diff_for_commit_prompt, ChangePattern, CommitFormat, CommitMessageOptions, CommitType, FileChange, FileChangeType, - ProjectContext, assemble_commit_message, build_changes_summary_from_paths, - build_commit_prompt, detect_change_patterns, extract_module_name, infer_file_type, - parse_commit_analysis_value, parse_commit_type_label, prepare_commit_prompt, - truncate_diff_for_commit_prompt, + ProjectContext, }, ports::{ CommitAiAnalysisRequest, FunctionAgentAiPort, FunctionAgentFuture, FunctionAgentGitPort, - GitCommitSnapshot, StartchatGitSnapshot, WorkStateAiAnalysisRequest, + FunctionAgentRuntimeFacade, GitCommitSnapshot, StartchatGitSnapshot, + WorkStateAiAnalysisRequest, }, startchat_func_agent::{ - ActionPriority, GitWorkState, QuickActionType, TimeOfDay, WorkStateOptions, build_complete_analysis_prompt, combine_git_diffs, limit_quick_actions, normalize_predicted_actions, parse_complete_analysis_value, parse_git_status_porcelain, parse_predicted_actions_from_values, parse_quick_actions_from_values, time_of_day_for_hour, + ActionPriority, AheadBehind, GitWorkState, QuickActionType, TimeOfDay, WorkStateOptions, }, + AgentErrorType, Language, }; +use std::future::Future; +use std::pin::pin; +use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; struct FunctionAgentPortStub; @@ -46,12 +50,15 @@ impl FunctionAgentGitPort for FunctionAgentPortStub { Box::pin(async { Ok(StartchatGitSnapshot { current_branch: "main".to_string(), - status_porcelain: String::new(), - unstaged_diff: String::new(), - staged_diff: String::new(), - unpushed_commits: 0, - ahead_behind: None, - last_commit_timestamp: None, + status_porcelain: " M src/lib.rs\nA staged.rs\n".to_string(), + unstaged_diff: "unstaged".to_string(), + staged_diff: "staged".to_string(), + unpushed_commits: 2, + ahead_behind: Some(AheadBehind { + ahead: 1, + behind: 0, + }), + last_commit_timestamp: Some(900), }) }) } @@ -100,6 +107,74 @@ impl FunctionAgentAiPort for FunctionAgentPortStub { } } +struct EmptyCommitPortStub; + +impl FunctionAgentGitPort for EmptyCommitPortStub { + fn git_commit_snapshot( + &self, + _repo_path: String, + ) -> FunctionAgentFuture<'_, GitCommitSnapshot> { + Box::pin(async { + Ok(GitCommitSnapshot { + staged_paths: Vec::new(), + staged_count: 0, + unstaged_count: 1, + diff_content: String::new(), + project_context: ProjectContext::default(), + }) + }) + } + + fn startchat_git_snapshot( + &self, + _repo_path: String, + ) -> FunctionAgentFuture<'_, StartchatGitSnapshot> { + FunctionAgentPortStub.startchat_git_snapshot(_repo_path) + } +} + +struct NoGitExpectedPortStub; + +impl FunctionAgentGitPort for NoGitExpectedPortStub { + fn git_commit_snapshot( + &self, + _repo_path: String, + ) -> FunctionAgentFuture<'_, GitCommitSnapshot> { + panic!("git_commit_snapshot should not be called") + } + + fn startchat_git_snapshot( + &self, + _repo_path: String, + ) -> FunctionAgentFuture<'_, StartchatGitSnapshot> { + panic!("startchat_git_snapshot should not be called") + } +} + +fn block_on(future: F) -> F::Output { + let waker = noop_waker(); + let mut context = Context::from_waker(&waker); + let mut future = pin!(future); + loop { + match Future::poll(future.as_mut(), &mut context) { + Poll::Ready(value) => return value, + Poll::Pending => std::thread::yield_now(), + } + } +} + +fn noop_waker() -> Waker { + unsafe fn clone(_: *const ()) -> RawWaker { + RawWaker::new(std::ptr::null(), &VTABLE) + } + unsafe fn wake(_: *const ()) {} + unsafe fn wake_by_ref(_: *const ()) {} + unsafe fn drop(_: *const ()) {} + + static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake_by_ref, drop); + unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) } +} + #[test] fn git_commit_options_preserve_existing_defaults() { let options = CommitMessageOptions::default(); @@ -155,11 +230,9 @@ fn git_function_agent_summary_helpers_preserve_commit_shape() { assert_eq!(summary.file_changes[0].path, "src/crates/core/lib.rs"); assert_eq!(summary.file_changes[0].file_type, "rs"); assert!(summary.affected_modules.contains(&"core".to_string())); - assert!( - summary - .change_patterns - .contains(&ChangePattern::DocumentationUpdate) - ); + assert!(summary + .change_patterns + .contains(&ChangePattern::DocumentationUpdate)); let message = assemble_commit_message( "feat(core): add boundary helper", @@ -230,11 +303,9 @@ fn git_function_agent_commit_prompt_preparation_preserves_truncation_boundary() let prepared = prepare_commit_prompt(template, &"x".repeat(140), &context, &options, 120); assert!(prepared.truncated); - assert!( - prepared - .diff_content - .ends_with("\n\n... [content truncated] ...") - ); + assert!(prepared + .diff_content + .ends_with("\n\n... [content truncated] ...")); assert!(prepared.prompt.contains("Diff: ")); assert!(prepared.prompt.contains("library")); @@ -406,6 +477,117 @@ fn function_agent_ports_keep_ai_and_git_boundaries_explicit() { let _future = ai_port.analyze_work_state(work_state_request); } +#[test] +fn function_agent_runtime_facade_generates_commit_message_from_ports() { + let ports = FunctionAgentPortStub; + let facade = FunctionAgentRuntimeFacade::new(&ports, &ports); + + let message = block_on( + facade.generate_commit_message("repo".to_string(), CommitMessageOptions::default()), + ) + .unwrap(); + + assert_eq!(message.title, "chore: test"); + assert_eq!(message.full_message, "chore: test"); + assert_eq!(message.commit_type, CommitType::Chore); + assert_eq!(message.confidence, 1.0); + assert_eq!(message.changes_summary.files_changed, 1); + assert_eq!(message.changes_summary.file_changes[0].path, "src/lib.rs"); +} + +#[test] +fn function_agent_runtime_facade_preserves_empty_staging_error() { + let git = EmptyCommitPortStub; + let ai = FunctionAgentPortStub; + let facade = FunctionAgentRuntimeFacade::new(&git, &ai); + + let error = block_on( + facade.generate_commit_message("repo".to_string(), CommitMessageOptions::default()), + ) + .unwrap_err(); + + assert_eq!(error.error_type, AgentErrorType::InvalidInput); + assert_eq!( + error.message, + "Staging area is empty, please stage files first" + ); +} + +#[test] +fn function_agent_runtime_facade_builds_work_state_from_ports_without_surface_logic() { + let ports = FunctionAgentPortStub; + let facade = FunctionAgentRuntimeFacade::new(&ports, &ports); + let options = WorkStateOptions { + predict_next_actions: false, + include_quick_actions: false, + ..WorkStateOptions::default() + }; + + let analysis = block_on(facade.analyze_work_state( + "repo".to_string(), + options, + 960, + 14, + "2026-05-19T12:00:00+08:00".to_string(), + )) + .unwrap(); + + let git_state = analysis.current_state.git_state.unwrap(); + assert_eq!(analysis.current_state.summary, "stub"); + assert_eq!(git_state.current_branch, "main"); + assert_eq!(git_state.unstaged_files, 1); + assert_eq!(git_state.staged_files, 1); + assert_eq!(git_state.unpushed_commits, 2); + assert_eq!(git_state.ahead_behind.unwrap().ahead, 1); + assert_eq!( + analysis.current_state.time_info.minutes_since_last_commit, + Some(1) + ); + assert_eq!( + analysis.current_state.time_info.time_of_day, + TimeOfDay::Afternoon + ); + assert!(analysis.predicted_actions.is_empty()); + assert!(analysis.quick_actions.is_empty()); + assert_eq!(analysis.analyzed_at, "2026-05-19T12:00:00+08:00"); +} + +#[test] +fn function_agent_runtime_facade_honors_disabled_git_analysis_boundary() { + let git = NoGitExpectedPortStub; + let ai = FunctionAgentPortStub; + let facade = FunctionAgentRuntimeFacade::new(&git, &ai); + let options = WorkStateOptions { + analyze_git: false, + predict_next_actions: false, + include_quick_actions: false, + ..WorkStateOptions::default() + }; + + let analysis = block_on(facade.analyze_work_state( + "repo".to_string(), + options, + 960, + 9, + "2026-05-19T09:00:00+08:00".to_string(), + )) + .unwrap(); + + assert_eq!(analysis.current_state.summary, "stub"); + assert!(analysis.current_state.git_state.is_none()); + assert!(analysis + .current_state + .time_info + .minutes_since_last_commit + .is_none()); + assert_eq!( + analysis.current_state.time_info.time_of_day, + TimeOfDay::Morning + ); + assert!(analysis.predicted_actions.is_empty()); + assert!(analysis.quick_actions.is_empty()); +} + #[test] fn git_function_agent_utils_preserve_change_classification() { assert_eq!(infer_file_type("src/main.rs"), "rs"); diff --git a/src/crates/product-domains/tests/miniapp_contracts.rs b/src/crates/product-domains/tests/miniapp_contracts.rs index 1f1b1031a..48d1a4562 100644 --- a/src/crates/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/product-domains/tests/miniapp_contracts.rs @@ -3,15 +3,15 @@ use bitfun_product_domains::miniapp::bridge_builder::{build_bridge_script, build_csp_content}; use bitfun_product_domains::miniapp::compiler::compile; use bitfun_product_domains::miniapp::customization::{ - MAX_DECLINED_BUILTIN_UPDATES, MiniAppCustomizationBaseline, MiniAppCustomizationLocalSnapshot, - MiniAppCustomizationMetadata, MiniAppCustomizationOrigin, MiniAppCustomizationOriginKind, apply_draft_customization_metadata, decline_builtin_update_metadata, declined_builtin_update_needs_local_snapshot, is_current_declined_builtin_update, - mark_builtin_update_available_metadata, + mark_builtin_update_available_metadata, MiniAppCustomizationBaseline, + MiniAppCustomizationLocalSnapshot, MiniAppCustomizationMetadata, MiniAppCustomizationOrigin, + MiniAppCustomizationOriginKind, MAX_DECLINED_BUILTIN_UPDATES, }; use bitfun_product_domains::miniapp::draft::{ - MINIAPP_DRAFT_STATUS_APPLIED, MINIAPP_DRAFT_STATUS_DRAFT, build_draft_manifest, - build_draft_response, + build_draft_manifest, build_draft_response, MINIAPP_DRAFT_STATUS_APPLIED, + MINIAPP_DRAFT_STATUS_DRAFT, }; use bitfun_product_domains::miniapp::exporter::{ExportCheckResult, ExportTarget}; use bitfun_product_domains::miniapp::host_routing::{ @@ -27,25 +27,30 @@ use bitfun_product_domains::miniapp::lifecycle::{ use bitfun_product_domains::miniapp::permission_policy::resolve_policy; use bitfun_product_domains::miniapp::ports::{ MiniAppInstallDepsRequest, MiniAppPortError, MiniAppPortErrorKind, MiniAppPortFuture, - MiniAppRuntimePort, + MiniAppRuntimeFacade, MiniAppRuntimePort, MiniAppStoragePort, }; use bitfun_product_domains::miniapp::runtime::{ - RuntimeKind, candidate_dirs, candidate_executable_path, runtime_lookup_order, - version_manager_roots, versioned_executable_candidate, + candidate_dirs, candidate_executable_path, runtime_lookup_order, version_manager_roots, + versioned_executable_candidate, RuntimeKind, }; use bitfun_product_domains::miniapp::storage::{ - COMPILED_HTML, CUSTOMIZATION_JSON, DRAFT_JSON, DRAFTS_CLEANUP_MARKER, DRAFTS_CLEANUP_PREFIX, - DRAFTS_DIR, EMPTY_ESM_DEPENDENCIES_JSON, EMPTY_STORAGE_JSON, ESM_DEPS_JSON, INDEX_HTML, - META_JSON, MiniAppImportLayout, MiniAppStorageLayout, PACKAGE_JSON, PLACEHOLDER_COMPILED_HTML, + build_import_fallbacks, build_package_json, parse_npm_dependencies, MiniAppImportLayout, + MiniAppStorageLayout, COMPILED_HTML, CUSTOMIZATION_JSON, DRAFTS_CLEANUP_MARKER, + DRAFTS_CLEANUP_PREFIX, DRAFTS_DIR, DRAFT_JSON, EMPTY_ESM_DEPENDENCIES_JSON, EMPTY_STORAGE_JSON, + ESM_DEPS_JSON, INDEX_HTML, META_JSON, PACKAGE_JSON, PLACEHOLDER_COMPILED_HTML, REQUIRED_SOURCE_FILES, SOURCE_DIR, STORAGE_JSON, STYLE_CSS, UI_JS, VERSIONS_DIR, WORKER_JS, - build_import_fallbacks, build_package_json, parse_npm_dependencies, }; use bitfun_product_domains::miniapp::types::{ FsPermissions, MiniApp, MiniAppPermissions, MiniAppRuntimeState, MiniAppSource, NetPermissions, NotificationPermissions, NpmDep, }; -use bitfun_product_domains::miniapp::worker::{InstallResult, install_command_for_runtime}; +use bitfun_product_domains::miniapp::worker::{install_command_for_runtime, InstallResult}; +use std::collections::BTreeMap; +use std::future::Future; use std::path::{Path, PathBuf}; +use std::pin::pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; struct RuntimePortStub; @@ -71,6 +76,193 @@ impl MiniAppRuntimePort for RuntimePortStub { } } +#[derive(Clone)] +struct StoragePortStub { + state: Arc>, +} + +struct StoragePortStubState { + current: MiniApp, + versions: BTreeMap, + save_count: usize, + saved_version_numbers: Vec, +} + +impl StoragePortStub { + fn new(current: MiniApp) -> Self { + Self { + state: Arc::new(Mutex::new(StoragePortStubState { + current, + versions: BTreeMap::new(), + save_count: 0, + saved_version_numbers: Vec::new(), + })), + } + } + + fn current(&self) -> MiniApp { + self.state.lock().unwrap().current.clone() + } + + fn save_count(&self) -> usize { + self.state.lock().unwrap().save_count + } + + fn saved_version_numbers(&self) -> Vec { + self.state.lock().unwrap().saved_version_numbers.clone() + } +} + +impl MiniAppStoragePort for StoragePortStub { + fn list_app_ids(&self) -> MiniAppPortFuture<'_, Vec> { + let app_id = self.state.lock().unwrap().current.id.clone(); + Box::pin(async move { Ok(vec![app_id]) }) + } + + fn load(&self, app_id: String) -> MiniAppPortFuture<'_, MiniApp> { + let result = { + let state = self.state.lock().unwrap(); + if state.current.id == app_id { + Ok(state.current.clone()) + } else { + Err(MiniAppPortError::new( + MiniAppPortErrorKind::NotFound, + format!("App not found: {app_id}"), + )) + } + }; + Box::pin(async move { result }) + } + + fn load_meta( + &self, + app_id: String, + ) -> MiniAppPortFuture<'_, bitfun_product_domains::miniapp::types::MiniAppMeta> { + let result = { + let state = self.state.lock().unwrap(); + if state.current.id == app_id { + Ok((&state.current).into()) + } else { + Err(MiniAppPortError::new( + MiniAppPortErrorKind::NotFound, + format!("App not found: {app_id}"), + )) + } + }; + Box::pin(async move { result }) + } + + fn load_source(&self, app_id: String) -> MiniAppPortFuture<'_, MiniAppSource> { + let result = { + let state = self.state.lock().unwrap(); + if state.current.id == app_id { + Ok(state.current.source.clone()) + } else { + Err(MiniAppPortError::new( + MiniAppPortErrorKind::NotFound, + format!("App not found: {app_id}"), + )) + } + }; + Box::pin(async move { result }) + } + + fn save(&self, app: MiniApp) -> MiniAppPortFuture<'_, ()> { + let state = self.state.clone(); + Box::pin(async move { + let mut state = state.lock().unwrap(); + state.current = app; + state.save_count += 1; + Ok(()) + }) + } + + fn save_version( + &self, + _app_id: String, + version: u32, + app: MiniApp, + ) -> MiniAppPortFuture<'_, ()> { + let state = self.state.clone(); + Box::pin(async move { + let mut state = state.lock().unwrap(); + state.versions.insert(version, app); + state.saved_version_numbers.push(version); + Ok(()) + }) + } + + fn load_app_storage(&self, _app_id: String) -> MiniAppPortFuture<'_, serde_json::Value> { + Box::pin(async { Ok(serde_json::json!({})) }) + } + + fn save_app_storage( + &self, + _app_id: String, + _key: String, + _value: serde_json::Value, + ) -> MiniAppPortFuture<'_, ()> { + Box::pin(async { Ok(()) }) + } + + fn delete(&self, _app_id: String) -> MiniAppPortFuture<'_, ()> { + Box::pin(async { Ok(()) }) + } + + fn list_versions(&self, _app_id: String) -> MiniAppPortFuture<'_, Vec> { + let versions = self + .state + .lock() + .unwrap() + .versions + .keys() + .copied() + .collect(); + Box::pin(async move { Ok(versions) }) + } + + fn load_version(&self, _app_id: String, version: u32) -> MiniAppPortFuture<'_, MiniApp> { + let result = self + .state + .lock() + .unwrap() + .versions + .get(&version) + .cloned() + .ok_or_else(|| { + MiniAppPortError::new( + MiniAppPortErrorKind::NotFound, + format!("Version v{version} not found"), + ) + }); + Box::pin(async move { result }) + } +} + +fn block_on(future: F) -> F::Output { + let waker = noop_waker(); + let mut context = Context::from_waker(&waker); + let mut future = pin!(future); + loop { + match Future::poll(future.as_mut(), &mut context) { + Poll::Ready(value) => return value, + Poll::Pending => std::thread::yield_now(), + } + } +} + +fn noop_waker() -> Waker { + unsafe fn clone(_: *const ()) -> RawWaker { + RawWaker::new(std::ptr::null(), &VTABLE) + } + unsafe fn wake(_: *const ()) {} + unsafe fn wake_by_ref(_: *const ()) {} + unsafe fn drop(_: *const ()) {} + + static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake_by_ref, drop); + unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) } +} + #[test] fn miniapp_csp_content_preserves_net_allow_contract() { let permissions = MiniAppPermissions { @@ -550,6 +742,96 @@ fn miniapp_ports_keep_runtime_boundary_lightweight() { let _future = port.detect_runtime(); } +#[test] +fn miniapp_runtime_facade_persists_port_backed_lifecycle_transitions() { + let mut app = sample_miniapp_for_lifecycle(MiniAppSource { + css: "body { color: black; }".to_string(), + npm_dependencies: vec![NpmDep { + name: "lodash".to_string(), + version: "^4.17.21".to_string(), + }], + ..MiniAppSource::default() + }); + app.runtime = build_runtime_state(app.version, app.updated_at, &app.source, true, false); + let storage = StoragePortStub::new(app); + let facade = MiniAppRuntimeFacade::new(&storage); + + let installed = block_on(facade.mark_deps_installed("demo".to_string())).unwrap(); + assert!(!installed.runtime.deps_dirty); + assert!(installed.runtime.worker_restart_required); + + let cleared = block_on(facade.clear_worker_restart_required("demo".to_string())).unwrap(); + assert!(!cleared.runtime.worker_restart_required); + + let recompiled = block_on(facade.persist_recompile_result( + "demo".to_string(), + "fresh".to_string(), + 2000, + )) + .unwrap(); + assert_eq!(recompiled.version, 3); + assert_eq!(recompiled.compiled_html, "fresh"); + assert!(!recompiled.runtime.ui_recompile_required); + + let synced_source = MiniAppSource { + css: "body { color: red; }".to_string(), + npm_dependencies: vec![NpmDep { + name: "lodash".to_string(), + version: "^4.17.21".to_string(), + }], + ..MiniAppSource::default() + }; + let synced = block_on(facade.persist_sync_from_fs_result( + "demo".to_string(), + synced_source, + "synced".to_string(), + 3000, + )) + .unwrap(); + assert_eq!(synced.version, 4); + assert_eq!(synced.source.css, "body { color: red; }"); + assert!(synced.runtime.deps_dirty); + assert!(synced.runtime.worker_restart_required); + assert_eq!(storage.saved_version_numbers(), vec![3]); + + let rolled_back = block_on(facade.rollback("demo".to_string(), 3, 4000)).unwrap(); + assert_eq!(rolled_back.version, 5); + assert_eq!(rolled_back.compiled_html, "fresh"); + assert!(rolled_back.runtime.worker_restart_required); + assert_eq!(storage.saved_version_numbers(), vec![3, 4]); +} + +#[test] +fn miniapp_runtime_facade_skips_save_when_restart_flag_already_clear() { + let mut app = sample_miniapp_for_lifecycle(MiniAppSource::default()); + app.runtime = build_runtime_state(app.version, app.updated_at, &app.source, false, false); + let storage = StoragePortStub::new(app); + let facade = MiniAppRuntimeFacade::new(&storage); + + let unchanged = block_on(facade.clear_worker_restart_required("demo".to_string())).unwrap(); + + assert!(!unchanged.runtime.worker_restart_required); + assert_eq!(storage.save_count(), 0); + assert_eq!(storage.current().version, 3); +} + +#[test] +fn miniapp_runtime_facade_preserves_storage_errors_without_state_writes() { + let app = sample_miniapp_for_lifecycle(MiniAppSource::default()); + let storage = StoragePortStub::new(app); + let facade = MiniAppRuntimeFacade::new(&storage); + + let missing_app = block_on(facade.mark_deps_installed("missing".to_string())).unwrap_err(); + assert_eq!(missing_app.kind, MiniAppPortErrorKind::NotFound); + assert_eq!(storage.save_count(), 0); + assert!(storage.saved_version_numbers().is_empty()); + + let missing_version = block_on(facade.rollback("demo".to_string(), 99, 4000)).unwrap_err(); + assert_eq!(missing_version.kind, MiniAppPortErrorKind::NotFound); + assert_eq!(storage.save_count(), 0); + assert!(storage.saved_version_numbers().is_empty()); +} + #[test] fn miniapp_draft_contract_preserves_manifest_and_response_shape() { let app = sample_miniapp_for_lifecycle(MiniAppSource::default()); @@ -759,12 +1041,10 @@ fn miniapp_customization_decline_policy_updates_existing_and_trims_old_records() metadata.declined_builtin_updates.len(), MAX_DECLINED_BUILTIN_UPDATES ); - assert!( - !metadata - .declined_builtin_updates - .iter() - .any(|record| record.source_hash == "hash-v5") - ); + assert!(!metadata + .declined_builtin_updates + .iter() + .any(|record| record.source_hash == "hash-v5")); } fn sample_miniapp_for_lifecycle(source: MiniAppSource) -> MiniApp {