From 3b5cddcd5c901842a8a2a8478bf5db40742d2f50 Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 7 Sep 2026 21:46:36 +0800 Subject: [PATCH 1/7] docs(migration): Define legacy scope and fixtures Establish the persistence inventory and source format used by the BitFun-to-OpenBitFun importer before implementing data transfers. - Map legacy settings, credentials, extensions, sessions, memories, and remote connection data to their persistence owners. - Document migration boundaries, implementation stages, and the distinction between static inventory and release validation. - Add a supported-source matrix and versioned fixtures covering durable records alongside runtime files that must be excluded. Provide a common reference for import adapters and recovery checks without treating the inventory as proof of real-device migration. --- ...GACY_DATA_MIGRATION_IMPLEMENTATION_PLAN.md | 907 ++++++++++++++++++ OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md | 569 +++++++++++ .../tests/fixtures/support-matrix.json | 78 ++ .../tests/fixtures/v0.2.19/README.md | 17 + .../fixtures/v0.2.19/fixture-manifest.json | 38 + .../v0.2.19/home/device_identity.json | 1 + .../fixtures/v0.2.19/home/memories/MEMORY.md | 4 + .../sessions/session-1/metadata.json | 7 + .../session-1/request-traces/trace.json | 1 + .../sessions/session-1/turns/turn-1.json | 1 + .../home/remote_connect_persistence.json | 11 + .../home/runtime-events/session-1.jsonl | 1 + .../tests/fixtures/v0.2.19/ssh/known_hosts | 1 + .../v0.2.19/ssh/remote_workspace.json | 4 + .../fixtures/v0.2.19/ssh/ssh_connections.json | 9 + .../v0.2.19/user-root/agents/researcher.md | 13 + .../v0.2.19/user-root/config/app.json | 20 + .../data/agent-runtime/coordination.sql | 45 + .../data/agent-runtime/ipc-v17/discovery.json | 1 + .../data/agent-runtime/ownership/writer.lock | 1 + .../user-root/data/memories/memories.sql | 6 + .../builtin-gomoku/.builtin-manifest.json | 1 + .../data/miniapps/builtin-gomoku/index.html | 1 + .../data/miniapps/builtin-gomoku/meta.json | 1 + .../data/miniapps/builtin-gomoku/storage.json | 1 + .../data/miniapps/custom-notes/index.html | 1 + .../data/miniapps/custom-notes/meta.json | 18 + .../data/miniapps/custom-notes/storage.json | 1 + .../user-root/data/workspace_data.json | 14 + .../user-root/skills/.system/builtin/SKILL.md | 6 + .../user-root/skills/user-skill/SKILL.md | 6 + 31 files changed, 1785 insertions(+) create mode 100644 OPENBITFUN_LEGACY_DATA_MIGRATION_IMPLEMENTATION_PLAN.md create mode 100644 OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md create mode 100644 src/crates/services/legacy-migration/tests/fixtures/support-matrix.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/fixture-manifest.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/device_identity.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/memories/MEMORY.md create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/metadata.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/request-traces/trace.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/turns/turn-1.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/remote_connect_persistence.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/runtime-events/session-1.jsonl create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/known_hosts create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/remote_workspace.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/ssh_connections.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/agents/researcher.md create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/config/app.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/coordination.sql create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/ipc-v17/discovery.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/ownership/writer.lock create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/memories/memories.sql create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/.builtin-manifest.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/index.html create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/meta.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/storage.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/index.html create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/meta.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/storage.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/workspace_data.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/skills/.system/builtin/SKILL.md create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/skills/user-skill/SKILL.md diff --git a/OPENBITFUN_LEGACY_DATA_MIGRATION_IMPLEMENTATION_PLAN.md b/OPENBITFUN_LEGACY_DATA_MIGRATION_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000..88b8c77f83 --- /dev/null +++ b/OPENBITFUN_LEGACY_DATA_MIGRATION_IMPLEMENTATION_PLAN.md @@ -0,0 +1,907 @@ +# OpenBitFun 旧版 BitFun 数据迁移实现方案 + +> 状态:V1 实现设计 +> 日期:2026-09-05 +> 上游盘点:[OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md](OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md) + +## 1. 结论 + +BitFun 到 OpenBitFun 的迁移不应实现为“把旧用户目录递归复制到新目录”,而应实现为一个**版本化、按数据领域解析、可恢复的离线导入系统**。 + +V1 采用以下产品与技术形态: + +- 提供一个与 OpenBitFun 一同安装、独立签名和打包的 **OpenBitFun Data Migrator**。 +- 主应用第一次启动时,在日志、全局配置、会话数据库等常规存储打开之前,只做轻量的旧数据发现和迁移状态判断。 +- 检测到旧 BitFun 数据且尚未作出选择时,主应用以 onboarding 模式启动 Data Migrator,然后在打开常规产品存储前退出;“立即迁移”“稍后提醒”“不再提醒”由迁移器提供。 +- 用户选择立即迁移后,Data Migrator 留在当前进程中继续预检和迁移;选择稍后或不再提醒时,迁移器记录决定并重新启动主应用。 +- Data Migrator 等待主应用及相关写入进程完全退出,按用户选择扫描、预演、暂存、校验和提交,最后按结果重新启动 OpenBitFun。 +- 设置页在“数据与维护”下永久保留“数据迁移”入口,支持首次启动后补迁、查看历史报告和重试失败领域。 +- 旧 BitFun 数据始终只读;迁移成功后也不自动删除。 +- 迁移以领域为原子提交单位,并通过全局迁移日志实现断点续传。不能宣称多个独立数据库和文件目录之间存在真实的全局事务。 +- 目标端已有数据默认优先;冲突必须可见,不静默覆盖。 +- 所有迁移操作只在存放旧数据的本机执行。远程控制、Peer Device 和 Detached Dispatch 不得把迁移请求静默转发到另一台机器或回退到控制端本地数据。 + +这比把迁移逻辑放进安装器或主应用进程更稳妥:安装器只负责安装和可选启动,主应用不在自身持有数据库、日志、WebView 配置和运行时锁时改写这些数据,迁移器也可以独立恢复、升级和记录结果。 + +## 2. 目标与非目标 + +### 2.1 V1 目标 + +V1 只保证高价值用户数据的连续性: + +1. 设置与服务凭据。 +2. Agents、用户 Skills 与用户 MiniApps。 +3. 工作区、会话与 Agent 任务状态。 +4. 记忆。 +5. 远程连接与设备。 + +系统还必须满足: + +- 能判断是否存在可迁移的旧 BitFun 安装。 +- 能给出迁移前扫描结果、预计范围、依赖项、冲突和不可迁移项。 +- 能安全处理中途退出、进程崩溃、磁盘不足和局部领域失败。 +- 同一来源可重复执行,重复执行不产生重复数据。 +- 能从旧数据版本升级到当前 OpenBitFun 数据模型,而不要求旧版应用先启动升级。 +- 能清楚说明“已迁移”“已跳过”“有冲突”“需要重新登录/重新配对”“失败且可重试”。 + +### 2.2 V1 非目标 + +V1 不做以下事情: + +- 不逐字节克隆整个 `%APPDATA%\bitfun` 目录。 +- 不迁移缓存、日志、临时文件、PID、锁、Socket、IPC 发现文件或 WebView 缓存。 +- 不迁移旧版内置资源;OpenBitFun 应从当前安装包重新生成内置 Agents、系统 Skills 和内置 MiniApps。 +- 不复制工作区中的真实项目文件;只迁移工作区登记、连接配置和会话引用。 +- 不在迁移完成后删除、移动或改名旧数据。 +- 不提供 BitFun 与 OpenBitFun 间的持续双向同步。 +- 不在 V1 支持逐个选择 Session。会话树与全局 Agent Runtime 协调数据库存在跨会话引用,拆分迁移会制造不可证明的悬空关系。 +- 不保证无法解密、与旧机器硬件绑定或协议已废弃的密钥继续可用;此类项目必须保留非敏感元数据并明确要求用户重新认证。 +- 不把未经识别的旧文件“先复制过去再说”。 + +## 3. 核心原则 + +### 3.1 按所有者迁移,不按目录迁移 + +每类数据由其当前产品领域所有者定义: + +- 如何识别旧版本; +- 哪些文件和表属于该领域; +- 如何解析和校验; +- 如何转换为当前模型; +- 如何检测重复和冲突; +- 如何提交及验证; +- 哪些数据必须作为一个原子子域处理。 + +通用迁移引擎只负责编排、锁、暂存、日志、恢复和报告,不猜测领域数据语义。 + +### 3.2 离线写入 + +只要迁移范围包含主应用常规会打开的数据,迁移必须在 OpenBitFun 主应用退出后执行。Data Migrator 需要取得目标迁移锁,并确认没有 OpenBitFun、BitFun 或 Agent Runtime 进程仍在写入选中的源和目标存储。 + +默认只请求优雅退出,不主动强杀进程。若仍有进程占用,迁移器应展示具体进程和“重试 / 取消”操作;强制结束进程只能是明确、二次确认的用户操作。 + +### 3.3 源只读,目标可恢复 + +- 不修改旧数据库,不在旧目录创建 marker,不对旧 SQLite 执行写入式 checkpoint。 +- 读取 SQLite 时使用 SQLite 备份/一致性快照能力,把主数据库及 WAL 中的有效内容合并到暂存数据库;不能只复制 `.sqlite` 文件。 +- 在提交前对目标端将被修改的数据创建可恢复备份或回滚记录。 +- 暂存目录与目标数据放在同一卷,确保文件替换可以使用原子 rename。 +- 每个领域提交后立即做读取验证,再写入提交标记。 + +### 3.4 可重复、可恢复、可解释 + +一次迁移由稳定的来源指纹、所选范围、转换器版本和计划哈希标识。每个源实体应有来源 ID/哈希到目标 ID 的映射。同一计划重试时: + +- 已成功且校验通过的领域直接跳过; +- 暂存未提交的领域可以重新生成; +- 提交状态不确定的领域先按结果校验,不盲目再写一次; +- 失败不抹掉已成功领域; +- 最终报告与界面状态一致。 + +## 4. 数据根目录与发现 + +### 4.1 Windows 示例 + +旧版 Windows 数据根目录的典型位置是: + +`%APPDATA%\bitfun` + +例如当前用户可能是: + +`C:\Users\wsp\AppData\Roaming\bitfun` + +实现中不得硬编码用户名或这个绝对路径。源路径应由平台目录解析器和旧产品标识共同解析;目标路径必须通过 OpenBitFun 的 AppPaths/PathManager 获取,不能在各迁移适配器内自行拼接。 + +还需要按领域发现用户主目录下的数据,例如: + +`%USERPROFILE%\.bitfun\memories` + +### 4.2 来源发现条件 + +轻量检测器只读取少量稳定标记,输出: + +- 是否存在旧 BitFun 根目录; +- 是否存在至少一个受支持领域的数据; +- 可识别的旧产品版本/模式版本; +- 路径可读性; +- 粗略数据量; +- 是否疑似仍有 BitFun 进程运行; +- 是否已经对同一来源完成过迁移。 + +目录存在本身不等于可迁移。空目录、只有缓存/日志的目录以及不受支持的未来格式不能触发“可迁移”结论。 + +如果发现多个来源目录,首次引导默认选择当前用户最可信的来源,并允许用户在迁移器内查看和改选;不得自动合并多个来源。 + +### 4.3 “第一次打开”的定义 + +“第一次打开”由 OpenBitFun 自己的数据目录中的 onboarding 状态判断,而不是通过“目标目录是否为空”判断。至少记录: + +- 来源指纹; +- 检测时间; +- 用户选择:立即、稍后、不再提醒; +- 最近一次提示版本; +- 对应迁移运行 ID。 + +“不再提醒”只关闭自动引导,不移除设置页常驻入口,也不把旧数据标记为已迁移。 + +## 5. V1 迁移分组 + +用户界面只展示五个高层组;组内的依赖和原子子域由系统管理,不能提供会破坏一致性的任意文件勾选。 + +### 5.1 设置与服务凭据 + +包含: + +- 用户设置、功能开关、主题/外观、语言、模型与服务配置; +- 可移植的提供商账户配置和服务端点; +- 由凭据引用关联的 API Key、Token 或密码; +- 与当前版本仍有明确语义映射的实验选项。 + +规则: + +- 只迁移当前设置所有者列入映射表的 key,并通过版本化转换器写入当前 schema。 +- 未知或已废弃 key 记录为“未支持”,不能原样注入当前配置。 +- 目标端已显式设置的值优先;只有目标端仍是缺省值或不存在时,才接受源值。 +- 凭据不通过日志、命令行参数、迁移报告或普通 JSON 暂存。 +- 凭据应通过旧凭据所有者/安全存储读取,并写入当前 Vault/系统凭据库。无法解密时迁移非敏感配置并标记“需要重新登录或重新填写凭据”。 +- 机器绑定或用户 SID 绑定的秘密不能通过简单文件复制绕过安全边界。 + +### 5.2 Agents、Skills 与 MiniApps + +#### Agents + +来源: + +`%APPDATA%\bitfun\agents` + +迁移所有正式的用户 Agent 定义及其由 Agent 所有者声明的附件。当前安装包提供的内置 Agent 不从旧目录覆盖。 + +处理规则: + +- 先解析 manifest/schema,再复制允许的资源; +- 保留稳定来源 ID,并在冲突时建立来源 ID 到新目标 ID 的映射; +- 引用 Skill、MiniApp 或模型配置的字段必须在相关领域映射完成后重写; +- 无法解析的 Agent 保留在隔离报告中,不注册到产品运行时。 + +#### Skills + +来源: + +`%APPDATA%\bitfun\skills` + +包含: + +- `skills` 下除根级 `.system` 目录外的用户 Skill; +- Skill manifest 声明的脚本、模板和资源。 + +排除: + +`%APPDATA%\bitfun\skills\.system` + +`.system` 由 OpenBitFun 当前版本重新生成。实现应以 `OPENBITFUN_SYSTEM_SKILL_DIR` 所代表的稳定产品事实识别系统目录,不在多个位置重复硬编码字符串。 + +迁移期间不执行 Skill 脚本、不加载插件、不运行 hook。路径校验必须拒绝越界路径、junction/reparse point 和循环链接。 + +#### MiniApps + +来源: + +`%APPDATA%\bitfun\data\miniapps` + +包含: + +- 用户安装或创建的非内置 MiniApp; +- MiniApp 所有者明确声明可迁移的用户存储和自定义数据。 + +排除: + +- 当前或旧版内置 MiniApp 的安装源; +- 可由安装包重新生成的 bundle; +- 下载缓存、构建缓存和临时运行状态。 + +内置身份应结合内置 ID 清单、`BUILTIN_APPS` 与 `.builtin-manifest.json` 判断,不能只根据目录名猜测。若内置 MiniApp 曾产生用户数据,应由 MiniApp 数据适配器把“内置代码”和“用户数据”分开:代码不迁移,兼容的用户数据可以迁移。 + +### 5.3 工作区、会话与 Agent 任务状态 + +这是一个用户可选组,但在内部至少包含“工作区/会话存储”和“Agent Runtime 协调数据库”两个有依赖的原子子域。 + +包含: + +- 工作区注册信息、最近使用记录和会话关联; +- 完整 Session 树、消息、附件引用、运行记录及所有者声明的索引; +- `runtime-events` 等恢复旧会话所必需的持久事件; +- `%APPDATA%\bitfun\data\agent-runtime\coordination.sqlite`。 + +`coordination.sqlite` 是会话数据的一部分,而不是可选缓存。旧 Session 中的 Subagent、任务关系、投递/协调状态依赖它。V1 中只要用户选择“工作区、会话与 Agent 任务状态”,迁移计划就必须自动包含该数据库,界面可以解释依赖但不能取消勾选。 + +排除 Agent Runtime 的短期进程状态: + +- `ownership`; +- `ipc-v*`; +- PID、lock、socket、端口发现文件; +- 运行实例租约、心跳和可重新生成的 discovery 状态; +- SQLite `-shm` 文件; +- 其他只在旧进程存活期间有效的数据。 + +SQLite `-wal` 不是独立目标文件,但如果存在,必须作为一致性读取输入,由 SQLite 备份流程合并进暂存数据库。源端保持只读,最终目标数据库不携带旧 `-wal`/`-shm`。 + +会话迁移还必须满足: + +- V1 不允许逐个选择 Session。 +- 先建立工作区、Session、Turn、Subagent/Task 等实体的完整 ID 映射,再写交叉引用。 +- 无法解析的新旧状态不能通过删除记录“修复”;应保留源记录、降级该会话并给出明确状态。 +- 工作区本地路径只作为引用迁移,不复制目录内容。路径不存在时仍保留记录并标记“需要重新定位”。 +- 远程工作区路径按远端 POSIX 路径处理,不能用 Windows `std::path` 语义重写。 +- 若协调数据库的 schema 不受支持或一致性校验失败,整个会话组不得标记成功。 + +### 5.4 记忆 + +来源至少包括: + +`%APPDATA%\bitfun\data\memories\memories.sqlite` + +`%USERPROFILE%\.bitfun\memories` + +包含: + +- 当前 Memory 所有者能够解析的结构化记忆; +- 与记忆关联、且 schema 明确支持的索引和附件; +- 文件型记忆目录中非临时、非生成物的数据。 + +规则: + +- 数据库通过一致性备份读取,不复制活动中的 SQLite 文件组合。 +- 迁移器不得在迁移阶段触发 embedding、总结、压缩或其他模型调用。 +- 可重新生成的全文/向量索引默认不迁移;导入事实数据后由当前版本按需重建。 +- 重复记忆优先使用稳定 ID 和规范化内容哈希去重。 +- 同 ID 不同内容时保留目标记录,并为源记录分配新 ID 或进入显式冲突列表,不能静默覆盖。 +- 文件型记忆只允许在声明的根目录内读取,拒绝路径穿越和链接越界。 + +### 5.5 远程连接与设备 + +包含两类需要分别原子处理的子域。 + +#### Remote Connect、设备、账户和机器人 + +可迁移项包括: + +- 设备显示名称、用户可识别的连接资料; +- 已授权设备/控制端的非敏感元数据; +- Remote Connect、IM Bot 等仍受支持的配置; +- 可由当前安全存储接收的认证资料。 + +默认排除: + +- 在线状态、心跳、relay lease、临时会话 token; +- 进程和 Socket 发现信息; +- 已过期的一次性配对码; +- 当前协议不再接受的缓存。 + +设备 ID 和加密身份不能一概复制。领域适配器必须把数据分成: + +1. 可移植资料:直接转换; +2. 可安全迁移的秘密:通过 Vault/系统安全存储转写; +3. 机器绑定身份:生成当前身份并提示重新配对; +4. 不受支持记录:保留可读报告,不注册为有效连接。 + +不得为了“看起来迁移成功”而复用可能导致两个设备共享同一活跃身份的密钥。 + +#### SSH 连接与远程工作区凭据 + +作为独立原子子域处理: + +- SSH 主机、跳板链、端口和用户名配置; +- 远程工作区登记; +- `known_hosts`/主机指纹; +- 密码、私钥口令等 Vault 引用。 + +目标端已有连接资料优先。`known_hosts` 冲突必须展示指纹差异,不能自动接受新的主机身份。私钥文件只迁移引用;只有产品已有明确“导入密钥”流程且用户选择后,才复制密钥,并保留严格文件权限。 + +## 6. 用户流程 + +### 6.1 首次启动引导 + +首次启动引导由 Data Migrator 的 onboarding 模式承载,避免为了显示引导而先初始化主应用 WebView、配置、日志、插件或会话服务。启动顺序应是: + +1. 解析当前平台的 OpenBitFun 数据根目录。 +2. 读取最小迁移 onboarding 状态。 +3. 运行只读的 legacy probe。 +4. 如果没有受支持的旧数据,继续正常启动。 +5. 如果用户已选择不再提醒,继续正常启动。 +6. 如果存在可迁移数据,生成 onboarding 请求,以最小平台进程启动能力启动 Data Migrator,然后直接结束本次主应用启动。 +7. Data Migrator 展示首次启动引导;用户选择“立即迁移”时在同一迁移器进程中继续扫描、确认和迁移。 +8. 用户选择“稍后提醒”或“不再提醒”时,迁移器原子写入独立 onboarding 状态,再携带一次性已处理 run ID 重启主应用。 +9. 重启后的主应用校验 onboarding 状态/run ID,通过检测后才创建日志和打开正常产品存储,避免重复拉起迁移器。 + +这段检测必须位于 `src/apps/desktop/src/lib.rs` 中 `LogConfig::new` 和 `initialize_global_config` 之前。检测器自身只能使用独立、极小且不会打开产品数据库的日志/诊断方式;启动迁移器也只能依赖最小平台进程能力,并在 Windows 明确隐藏控制台窗口。 + +引导页应解释: + +- 旧数据不会被删除; +- 迁移期间 OpenBitFun 需要关闭; +- 可以选择迁移范围; +- 稍后可以从设置再次进入; +- 迁移器会在结束后重新打开 OpenBitFun。 + +首次提示不应直接开始迁移,也不应把“关闭窗口”解释为用户同意。关闭 onboarding 窗口默认等价于“稍后提醒”,并重新打开主应用。 + +### 6.2 设置中的常驻入口 + +在 `src/web-ui/src/app/scenes/settings/settingsRegistry.ts` 对应的“数据与维护”分类新增“数据迁移”。 + +入口至少展示: + +- 是否检测到旧 BitFun 数据; +- 上次扫描时间和来源; +- 最近一次迁移结果; +- 已成功/失败/跳过的领域; +- “扫描旧数据”“开始迁移”“查看报告”“重试失败项”; +- 已选择不再提醒时恢复首次引导提醒的选项。 + +从设置开始迁移时,如果存在正在运行的 Agent、终端任务、文件写入或未保存操作,主应用应先展示关闭影响。迁移请求确认后,仍走与首次启动相同的独立迁移器流程,Web UI 不直接读写用户数据。 + +### 6.3 Data Migrator 向导 + +建议步骤: + +1. 欢迎与来源选择。 +2. 只读扫描。 +3. 范围选择和依赖说明。 +4. 冲突、不可迁移项、磁盘空间和进程预检。 +5. 用户最终确认。 +6. 关闭/等待相关应用。 +7. 迁移进度。 +8. 结果与报告。 +9. 重新启动 OpenBitFun。 + +范围选择中,“工作区、会话与 Agent 任务状态”是一个整体选项;`coordination.sqlite` 作为强制依赖显示在详情中。 + +进度必须以领域、阶段和计数表达,不能用无法验证的伪百分比。取消只允许发生在安全边界: + +- 扫描和计划阶段可以立即取消; +- 暂存阶段可在当前可中断步骤后取消; +- 单个领域进入提交临界区后,必须完成提交或回滚到已知状态再响应取消。 + +## 7. 独立迁移器架构 + +### 7.1 为什么是独立应用 + +迁移器应是单独的可执行程序和应用包,而不是主应用中的隐藏窗口,原因包括: + +- 可以在主应用所有数据库、文件 watcher、日志和运行时完全关闭后工作; +- 不共享主应用 WebView profile,避免迁移浏览器状态时占用自己; +- 能独立获取目标数据锁; +- 崩溃恢复和升级边界清晰; +- 可由安装器、首次启动或设置入口统一启动; +- 可单独进行代码签名、包完整性校验和进程身份校验。 + +安装器可以提供“安装完成后迁移旧 BitFun 数据”的入口,但只负责启动同一个 Data Migrator,不能复制一套迁移实现。 + +### 7.2 进程交接协议 + +主应用与迁移器之间使用版本化请求文件,不把密钥或大段配置放在命令行。协议支持两种入口:首次启动的 `onboarding` 模式由迁移器收集选择;设置页的 `execute` 模式携带用户已确认的初始范围,但迁移器仍重新扫描并做最终确认。请求至少包含: + +- 请求协议版本; +- 随机 run ID 和一次性 nonce; +- 调用来源:首次启动、设置或安装器; +- 用户选择的来源引用与领域; +- 主应用进程 ID; +- 安装通道/产品身份; +- 请求创建时间和过期时间。 + +请求文件保存在当前用户专属、ACL 受限的迁移目录。迁移器必须校验: + +- 请求属于当前 OS 用户; +- 请求未过期且 nonce 未消费; +- 来源和目标由可信路径解析器重新解析,而不是直接信任任意绝对路径; +- 发起程序和待重启程序属于受信任的 OpenBitFun 安装; +- 协议版本受支持。 + +交接流程: + +1. 主应用落盘并 fsync 请求。 +2. 主应用使用共享进程管理器启动迁移器;Windows 子进程不得闪现控制台窗口。 +3. 迁移器读取请求并开始等待。 +4. 主应用停止接收新任务,优雅关闭 Agent Runtime、数据库、watcher 和窗口。 +5. 迁移器确认相关进程退出并获取独占迁移锁。 +6. 执行迁移。 +7. 写入最终结果和 onboarding 状态。 +8. 通过可信安装解析器定位主程序并重新启动。 +9. 新主应用读取 run ID,展示一次性迁移结果摘要。 + +不能接受请求文件传入的任意 executable path 并执行,避免形成命令执行入口。 + +### 7.3 运行拓扑 + +建议新增一个最小交付 profile: + +`src/apps/data-migrator` + +它只链接: + +- 稳定迁移 contracts; +- 通用迁移编排引擎; +- V1 五类数据的离线适配器; +- 平台路径、安全存储、文件系统和进程服务; +- 迁移器自身的轻量 UI。 + +它不应链接完整 `product-full`、启动 Agent Runtime、加载用户插件或初始化正常产品会话服务。 + +如果迁移器采用 Tauri/WebView UI,应使用独立 bundle ID、独立 WebView data directory 和最小命令白名单。UI 仍只调用 typed adapter,不直接使用 Tauri 文件系统 API。 + +## 8. 分层与代码所有权 + +以下是建议的物理落点;实现时可以根据现有 crate 粒度调整文件名,但不能改变所有权方向。 + +| 层 | 建议落点 | 责任 | +|---|---|---| +| 稳定合同与产品领域 | `src/crates/contracts/product-domains/src/legacy_migration/` | DTO、领域 ID、状态机、报告、兼容版本、远程姿态 | +| 通用服务 | `src/crates/services/services-integrations/src/legacy_migration/` 或最邻近的专用 service crate | 发现、锁、暂存、原子文件操作、SQLite 一致性快照、日志、恢复 | +| Product Assembly | `src/crates/assembly` 下的迁移装配模块 | 选择当前产品支持的领域适配器和 migrator delivery profile | +| 领域所有者 | 设置、Session、Memory、Remote、MiniApp 等现有 owner 附近 | 旧格式 reader、转换器、去重/冲突和当前格式 writer | +| Desktop | `src/apps/desktop` | 启动前 probe、引导、进程交接、重启后的结果提示 | +| Data Migrator | `src/apps/data-migrator` | 独立 UI、调用编排器、展示进度和报告 | +| Web UI | 设置注册表及数据迁移 feature | 展示状态和发起本机交接,不读写旧文件 | +| Installer | `OpenBitFun-Installer` | 安装和可选启动迁移器,不拥有迁移逻辑 | + +当前相关事实应复用而不是复制: + +- 系统 Skill 目录:`src/crates/execution/agent-runtime/src/skills/roots.rs` 中的 `OPENBITFUN_SYSTEM_SKILL_DIR`。 +- 内置 MiniApp 身份:`src/crates/contracts/product-domains/src/miniapp/builtin.rs` 中的 `BUILTIN_APPS` 及 `.builtin-manifest.json`。 +- Agent Runtime 协调数据库路径:`src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs` 中的 `data/agent-runtime/coordination.sqlite`。 +- 设置分类注册:`src/web-ui/src/app/scenes/settings/settingsRegistry.ts`。 +- 远程命令姿态:`src/crates/contracts/product-domains/src/remote_surface/table.rs`。 + +### 8.1 稳定合同 + +核心合同建议包含: + +- `LegacySourceDescriptor`:产品、版本、平台、解析后的根、来源指纹; +- `MigrationDomainId`:五个用户组及内部原子子域; +- `ScanFinding`:数量、大小、版本、风险、依赖和不可迁移原因; +- `MigrationSelection`:用户选择和系统自动补入的依赖; +- `MigrationPlan`:有序步骤、预计写入、冲突策略和计划哈希; +- `MigrationProgressEvent`:领域、阶段、已处理/总数、当前安全取消点; +- `MigrationConflict`:稳定代码、来源摘要、目标摘要和处理结果; +- `MigrationRunReport`:最终状态、领域结果、警告、重认证要求; +- `MigratorProtocolVersion`:主应用、安装器与迁移器的交接能力协商。 + +持久化 shape 只能追加带默认值的字段。旧迁移器和新主应用之间必须按能力协商,不能以安装包版本相同为前提。 + +### 8.2 领域适配器 + +每个领域实现统一的离线生命周期: + +1. `probe`:确认是否存在及格式版本; +2. `scan`:只读枚举和基本校验; +3. `plan`:转换、依赖、冲突和空间估算; +4. `stage`:写入隔离暂存区; +5. `validate_stage`:按当前 owner 读取暂存结果; +6. `commit`:事务或原子替换; +7. `validate_commit`:从目标读取验证; +8. `rollback`:仅处理该领域尚未确认成功的目标修改; +9. `summarize`:输出去敏报告。 + +禁止用“复制未知目录”的默认适配器兜底。 + +### 8.3 桌面命令 + +主应用侧命令保持窄接口并使用结构化 `request`,例如: + +- `get_legacy_migration_status`; +- `scan_legacy_migration`; +- `prepare_legacy_migration`; +- `get_legacy_migration_report`; +- `set_legacy_migration_prompt_preference`。 + +真正的 `execute` 只存在于 Data Migrator 本地进程,不暴露给普通 Web UI 或远程调用面。 + +这些命令必须登记到 Product Operation Registry: + +- remote workspace:本机产品数据操作,不路由到远程工作区; +- remote control:显式 unsupported; +- Peer Device:`ControllerLocal`,不能默认代理到 peer; +- Detached Dispatch:不支持。 + +## 9. 迁移日志与报告 + +迁移元数据建议位于目标数据根的专用目录,例如逻辑上的: + +`data/migrations/bitfun-to-openbitfun/` + +具体路径仍由统一路径管理器提供。目录至少包含: + +- `onboarding.json`:提示偏好和最近来源; +- `runs//request.json`:去敏后的请求; +- `runs//plan.json`:不可变计划及哈希; +- `runs//journal.jsonl`:追加式状态事件; +- `runs//report.json`:机器可读最终报告; +- `runs//report.html` 或 UI 可渲染摘要; +- `runs//stage/`:未提交暂存; +- `runs//backup/`:本次修改涉及的目标备份; +- `lock`:本机独占迁移锁。 + +日志状态至少包括: + +- `discovered`; +- `scanned`; +- `planned`; +- `waiting_for_processes`; +- `staging`; +- `validating_stage`; +- `committing`; +- `validating_commit`; +- `completed`; +- `completed_with_warnings`; +- `cancelled`; +- `failed_recoverable`; +- `failed_manual_action_required`。 + +每个领域独立记录 `not_started / staged / committed / verified / failed / skipped`。Journal 使用追加和 fsync;汇总文件通过临时文件加原子 rename 更新。 + +报告中不能包含凭据、完整消息正文、完整记忆正文或私钥内容。文件路径默认缩短到逻辑根下的相对路径;日志遵循仓库规则,只使用英文且不含 emoji。 + +## 10. 执行状态机 + +### 10.1 Discover + +- 解析受支持源; +- 读取最小版本标记; +- 计算不含敏感内容的来源指纹; +- 拒绝跨用户、权限异常或来源与目标相同的配置。 + +### 10.2 Scan + +- 所有领域只读扫描; +- 识别 schema 版本、实体数量、逻辑大小和风险; +- 检查源数据库完整性; +- 不生成目标记录,不执行用户代码。 + +### 10.3 Plan + +- 展开用户选择的强制依赖; +- 为每个领域选择明确的 `source_version -> current_version` 转换路径; +- 计算目标冲突、ID 映射策略、空间需求和执行顺序; +- 生成不可变计划哈希; +- 向用户展示后再次确认。 + +若用户选择会话组,计划必须包含 `coordination.sqlite`;缺失或不兼容时在确认前显示阻断原因。 + +### 10.4 Acquire + +- 等待 BitFun、OpenBitFun 及相关 Agent Runtime 退出; +- 获取源读取保护和目标独占迁移锁; +- 再次确认来源指纹未变化; +- 检查可用空间,至少覆盖暂存、备份和安全余量; +- 记录恢复点。 + +### 10.5 Stage + +- 按拓扑顺序执行领域 reader 和转换器; +- 先建立跨实体 ID 映射,再写引用; +- SQLite 使用一致性备份到 stage; +- 文件内容复制到 stage 后校验大小和哈希; +- 用户扩展内容保持惰性,不加载、不执行。 + +建议依赖顺序: + +1. 设置的非敏感结构和凭据引用; +2. Skills、MiniApps、Agents; +3. 工作区、会话、runtime-events 和协调数据库; +4. 记忆; +5. Remote Connect/设备和 SSH 子域; +6. 最终交叉引用修复。 + +实际提交顺序由 plan 固化,恢复时不得重新推测。 + +### 10.6 Validate Stage + +- 用当前版本 owner 的 reader 打开暂存结果; +- 执行 schema、外键、引用闭包和数据库完整性检查; +- 确认没有源路径越界、未声明文件或秘密泄漏到普通暂存; +- 会话组必须证明 Session/Subagent/Task 与协调数据库的引用闭包。 + +### 10.7 Commit + +每个原子子域单独提交: + +- 数据库使用 owner 事务或受支持的 import API; +- 独占目录可使用同卷原子 rename; +- 与目标已有数据合并时必须走 owner writer,不能替换整个目标目录; +- 写入提交意图、执行提交、读取验证、写入已验证标记; +- 进入提交临界区后不能留下“可能写了一半但无记录”的状态。 + +全局使用 saga:某领域提交后,后续领域失败时保留已经验证的数据,报告“部分完成”,并允许只重试失败领域。除非 owner 能证明补偿操作无损,否则不自动回滚已成功领域。 + +### 10.8 Finalize + +- 清理未需要保留的临时 stage; +- 保留报告、必要恢复信息和用户选择; +- 释放锁; +- 重新启动可信 OpenBitFun 主程序; +- 主应用展示一次性摘要; +- 旧 BitFun 数据保持不变。 + +## 11. 合并、冲突与 ID 策略 + +通用默认规则: + +| 情况 | 默认处理 | +|---|---| +| 目标不存在,来源有效 | 导入 | +| 稳定 ID 与内容哈希均相同 | 视为重复,跳过 | +| 同名但 ID 不同 | 两者保留;必要时给来源显示名加“来自 BitFun” | +| 同 ID、内容不同且可安全重映射 | 目标保留,来源生成新 ID,重写全部来源引用 | +| 同 ID、内容不同且不可安全重映射 | 不写入该实体,记录显式冲突 | +| 目标已有显式设置/凭据 | 目标优先 | +| 来源格式未知或校验失败 | 保留源,跳过目标写入,报告原因 | + +领域补充规则: + +- Settings:按 key 级别合并,只导入白名单及有明确转换器的项。 +- Agents/Skills/MiniApps:按稳定扩展 ID 和内容版本判断;不允许旧内置内容覆盖当前内置内容。 +- Sessions:ID 重映射必须覆盖工作区、Session、Turn、附件、runtime event、Subagent/Task 和协调库引用,不能只改顶层 Session ID。 +- Memory:内容哈希用于辅助去重,但不能把内容相似当作完全相同。 +- Remote:不能让两个设备共享一个应当唯一的活跃设备身份;安全优先于无提示连续登录。 +- SSH:host key 冲突要求用户确认,不能自动覆盖。 + +V1 不提供逐条通用冲突编辑器。可自动安全处理的按规则处理;需要用户判断的项目在计划或报告中分领域展示,并提供“跳过该项/该领域”的安全选择。 + +## 12. 安全与隐私 + +迁移器处理的是不可信的本地输入,必须按导入器而不是备份复制工具进行防护: + +- 所有文件操作限定在解析后的允许根目录内。 +- Windows 检查 reparse point/junction;其他平台检查 symlink,默认不跟随越界链接。 +- manifest 中的绝对路径和 `..` 越界一律拒绝。 +- 对文件数量、单文件大小、总大小、JSON 深度和数据库页数设置合理的资源上限,并把超限报告给用户。 +- SQLite 先做只读完整性和 schema 检查;不执行来源提供的扩展、触发器脚本或任意 SQL 文本。 +- 不执行 Agent、Skill、MiniApp、hook 或插件代码。 +- 凭据只在最短生命周期的受保护内存和系统安全存储通道中出现。 +- 日志、遥测和崩溃报告不能包含秘密或用户正文。 +- 迁移请求和报告使用当前用户 ACL;不允许跨 OS 用户迁移。 +- 迁移器和主程序互相验证产品身份、签名/安装位置和协议版本。 +- 默认不要求管理员权限,也不通过提权绕过 ACL。 +- 备份和暂存的保留策略必须可见;包含敏感材料的临时文件在失败后也要按策略安全清理。 +- 导入的可执行内容在首次实际启用时仍走当前产品的信任、权限和安全审查。 + +## 13. 远程场景边界 + +### 13.1 Remote Workspace + +迁移器运行在保存本机旧 BitFun 产品数据的设备上。迁移工作区登记和远端 POSIX 路径,但不连接 SSH 主机复制项目内容,也不把本地路径传给远端执行。 + +如果某条工作区记录需要当前版本不具备的远程能力,应保留记录并显示 unsupported/needs-repair,不能静默改成本地工作区。 + +### 13.2 Remote Control + +手机网页或 IM Bot 不能远程启动会关闭桌面应用的迁移流程。对应命令必须返回明确的“请在保存旧数据的桌面设备上操作”,不能空响应或在控制端本地执行。 + +迁移完成后,普通的结果通知可以通过已有远程事件机制展示,但不能把报告中的敏感路径或内容推送给远程控制端。 + +### 13.3 Peer Device Mode + +迁移命令为 `ControllerLocal`。每台设备只迁移自己的旧数据,不能由控制器把本机来源路径代理给 peer,也不能默认在 peer 上执行。 + +若未来需要迁移 peer 上的数据,必须在 peer 设备本地发起并完成独立的用户确认和进程交接。 + +### 13.4 Detached Dispatch + +V1 不支持在 Detached Dispatch 作业中迁移。迁移需要本机交互、关闭产品进程和独占用户数据锁,不属于无交互 durable job。 + +未来如提供 headless CLI,只能作为单独能力设计,具备明确源、目标、锁、报告和确认语义,不能复用调度作业做隐式迁移。 + +## 14. 打包、升级与重启 + +- Data Migrator 与 OpenBitFun 使用同一发布通道,但拥有独立应用身份和可执行文件。 +- 安装器验证迁移器存在且签名有效;缺失时不能退化为安装器自行复制文件。 +- 主应用和迁移器通过协议能力协商。新主应用遇到旧迁移器时应要求修复安装或更新,不能发送其不理解的请求。 +- updater 在迁移期间不得替换主应用或迁移器;迁移锁和更新锁要有确定的获取顺序。 +- 迁移器运行时不自更新。更新应在迁移开始前完成,或迁移结束后由主应用处理。 +- 重启路径从受信任安装注册信息解析,并继承正确安装通道;不执行请求中的任意路径。 +- 重启失败不影响已提交数据。结果页应提供“打开 OpenBitFun”和安装修复说明。 +- 卸载 BitFun 与清理旧数据是迁移以外的显式用户动作。 + +Windows 是首个必须完整支持的平台。macOS/Linux 如果存在历史 BitFun 版本,应使用同一领域协议和平台路径解析器,不能复制 Windows 绝对路径逻辑。 + +## 15. 测试与验证方案 + +### 15.1 固定样本 + +为每个受支持旧版本建立去敏、最小但关系完整的 fixture: + +- 空安装; +- 只有一个领域; +- 五个领域齐全; +- 含 Session/Subagent/Task/coordination 交叉引用; +- SQLite 存在未 checkpoint 的 WAL; +- 目标已有数据和 ID 冲突; +- 无效 JSON、未知 schema、损坏 SQLite; +- 路径穿越、junction/symlink、超大文件; +- 凭据可迁移与不可解密; +- 远程设备身份不可移植; +- 中英文及特殊字符路径。 + +fixture 必须由 owner reader 生成或验证,避免测试只证明迁移器和自造假格式彼此一致。 + +### 15.2 单元与契约测试 + +- 版本探测和转换器选择; +- 各领域 include/exclude 规则; +- `.system` Skills 排除; +- 内置 MiniApp 与用户数据拆分; +- 会话选择自动包含 `coordination.sqlite`; +- ID 映射和引用闭包; +- 目标优先、重复跳过、冲突可见; +- 报告去敏; +- 交接协议版本和过期 nonce; +- Product Operation Registry 的本机/远程姿态。 + +### 15.3 集成测试 + +- 在隔离目录运行完整 scan → plan → stage → commit → verify; +- 对同一来源连续迁移两次,第二次不产生重复; +- 源目录迁移前后哈希一致; +- 目标 owner 能正常读取迁移结果; +- SQLite WAL 内容进入目标且 `-shm` 不进入目标; +- 会话及旧 Subagent 在导入后可被当前 owner 解析并恢复关系; +- 每个状态转换点注入崩溃,重启后能继续或给出确定状态; +- 某领域失败后,其他已验证领域保持可用,重试只处理失败项; +- 磁盘不足、权限不足和占用冲突不产生半提交。 + +### 15.4 进程与打包测试 + +- 主应用生成请求后优雅退出; +- migrator 等待所有 writer 并获取独占锁; +- 迁移结束后从可信安装位置重启; +- 请求路径注入不能启动任意程序; +- 安装、升级、修复安装和卸载组合; +- Windows 下迁移器和子进程不闪现控制台; +- 迁移期间 updater 不与 migrator 互相覆盖; +- 代码签名和包内容闭包。 + +### 15.5 手工验证 + +发布前至少覆盖: + +- 全新安装首次引导; +- “稍后提醒”“不再提醒”和设置常驻入口; +- 五类范围选择及强制依赖说明; +- 关闭主应用、迁移进度、取消安全点; +- 完成、有警告、可恢复失败和重启失败; +- 屏幕阅读器、键盘操作、本地化和长路径展示。 + +手工 UI 验证需要按仓库规则单独显式执行;本文档落地本身不启动开发服务器,也不构成运行时/UI 验证。 + +## 16. 分阶段实施 + +### Phase 0:格式冻结与样本 + +- 为五个领域指定 owner。 +- 从受支持 BitFun 版本生成去敏 fixture。 +- 明确每个旧 schema 的最低支持版本。 +- 固化 include/exclude 表、数据依赖和来源指纹算法。 +- 对 `coordination.sqlite` 的表关系及 Session 引用做闭包审计。 + +交付:版本支持矩阵、fixture、领域责任表。 + +### Phase 1:合同与无 UI 引擎 + +- 新增 migration contracts、状态机、journal、报告和锁。 +- 实现平台路径发现、只读 SQLite snapshot、暂存和领域原子提交工具。 +- 建立 crash injection 和 idempotency 测试框架。 +- 实现 dry-run CLI/test harness,仅用于开发和自动化验证。 + +交付:可在隔离目录完成扫描、计划和无损演练的核心。 + +### Phase 2:五类领域适配器 + +建议按依赖顺序实现: + +1. 设置和凭据; +2. Skills、MiniApps、Agents; +3. 工作区、Session、runtime-events、`coordination.sqlite`; +4. Memory; +5. Remote Connect、设备和 SSH。 + +每个适配器必须同时交付旧格式 fixture、转换测试、冲突测试、当前 owner 读取验证和去敏报告。 + +### Phase 3:独立 Data Migrator + +- 增加最小 migrator delivery profile 和 UI。 +- 实现进程交接、独占锁、恢复、取消和可信重启。 +- 建立独立 bundle ID、WebView profile、签名和安装布局。 +- 完成 Windows 进程/锁/打包测试。 + +### Phase 4:产品入口 + +- 将轻量 probe 放到 Desktop 正常存储初始化之前。 +- 增加首次启动引导。 +- 在“数据与维护”增加常驻入口和历史报告。 +- 将命令加入 Product Operation Registry,并验证 Remote/Peer/Dispatch 显式边界。 +- 让安装器仅启动同一个迁移器。 + +### Phase 5:发布与观测 + +- 小范围发布,观测仅包含去敏结果码、领域状态、耗时和失败阶段。 +- 建立不含用户正文的失败诊断导出。 +- 根据真实旧版本补充转换器和 fixture。 +- 达到验收门槛后扩大推送,不以自动删除旧数据作为收尾。 + +## 17. V1 验收标准 + +V1 只有同时满足以下条件才算完成: + +- Windows 能正确发现 `%APPDATA%\bitfun`,且实现不硬编码具体用户名。 +- 首次启动检测发生在正常日志和全局配置初始化之前。 +- 用户可以立即迁移、稍后提醒或不再提醒。 +- 设置“数据与维护”中始终存在迁移入口。 +- 迁移在独立、签名的 Data Migrator 中执行,主应用在写入前已退出。 +- 五个用户组均有明确 owner、旧版本 reader、转换器、验证器和报告。 +- `agents` 全部正式用户内容、`skills` 中非 `.system` 内容以及非内置 MiniApps 按规则迁移。 +- 会话组强制包含 `data/agent-runtime/coordination.sqlite`,并验证 Session/Subagent/Task 引用闭包。 +- `ownership`、`ipc-v*`、PID/lock/socket/discovery 和 SQLite `-shm` 不进入目标。 +- SQLite WAL 中已提交内容不会因只复制主文件而丢失。 +- 目标已有数据默认不被覆盖,所有冲突可见。 +- 迁移可重试、可恢复且重复执行不重复导入。 +- 旧 BitFun 数据在成功、失败和取消后均保持不变。 +- 凭据和用户正文不进入日志、命令行、遥测或普通报告。 +- 远程调用返回明确 unsupported,Peer 命令为 `ControllerLocal`,无静默本地回退。 +- 完成后能可信重启 OpenBitFun;重启失败不回滚已验证数据。 +- fixture、领域集成、崩溃恢复、进程交接和打包测试通过。 +- 每个受支持旧版本都有至少一个包含会话与 `coordination.sqlite` 的端到端样本。 + +## 18. 已确定的 V1 决策 + +以下事项不再留给实现阶段临时决定: + +1. 使用独立 Data Migrator,不在运行中的主应用内直接迁移。 +2. 安装器只启动迁移器,不拥有第二套迁移逻辑。 +3. 旧源数据只读且永不自动删除。 +4. 迁移按领域 owner 解析,不递归复制整个用户目录。 +5. 用户界面采用五个高层组。 +6. `agents` 全部正式用户 Agent 纳入。 +7. `skills` 排除 `.system`,其他用户 Skills 纳入。 +8. `data/miniapps` 只迁移非内置 MiniApp;内置代码重建,用户数据另行适配。 +9. `data/agent-runtime/coordination.sqlite` 是会话组的强制组成部分。 +10. V1 不支持逐个 Session 选择。 +11. 目标端已有数据默认优先,冲突显式报告。 +12. 领域原子提交加全局 saga,不虚构跨领域全局事务。 +13. 首次检测位于常规产品存储打开之前。 +14. “数据与维护”永久保留迁移入口。 +15. 迁移只在数据所在本机执行,远程场景明确降级。 +16. 用户扩展内容在迁移期间保持惰性,绝不执行。 + +## 19. 后续可扩展项 + +不阻塞 V1,但合同应允许后续增加: + +- 受支持旧版本的增量转换器; +- macOS/Linux 历史数据路径; +- 经明确设计的 headless 导入工具; +- 用户导出的离线迁移包; +- 更细的领域内预览和冲突选择; +- 完成后的显式旧数据清理向导; +- 对不可移植设备身份的辅助重新配对流程。 + +这些扩展仍必须遵循源只读、owner-aware、目标优先、可恢复和远程显式降级的基本原则。 diff --git a/OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md b/OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md new file mode 100644 index 0000000000..5bf6ad0382 --- /dev/null +++ b/OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md @@ -0,0 +1,569 @@ +# BitFun → OpenBitFun 旧版持久化数据迁移清单 + +> 状态:静态核对完成(实现前清单;尚未实现或运行验证) +> 品牌更名提交:`8784bbc4258131b4e74e6f9192a23b43c454fa94` +> 最后更新:2026-09-05 + +## 1. 目的与结论口径 + +本文梳理品牌名从 BitFun 改为 OpenBitFun 后发生路径或存储命名变化的持久化数据,为后续迁移系统提供输入。每一项均区分: + +- **P0 必须迁移**:丢失会直接造成配置、会话、身份、凭据或用户创作内容不可用。 +- **P1 应迁移**:属于有价值的历史数据、工作状态或可感知偏好;缺失会造成明显体验或历史损失。 +- **P2 可选迁移**:可重建或价值较低,迁移主要用于减少升级扰动。 +- **不迁移**:缓存、日志、锁、PID、临时事务目录、可安全重建的索引。 +- **待核对**:尚未完成 owner、格式或生命周期核对,不能据此直接实现迁移。 + +最重要的架构结论是:**迁移系统不能只做旧目录到新目录的递归复制**。至少 `config/app.json`、`data/workspace_data.json`、cron、项目权限、Remote Connect 和 Subscription Auth 存在品牌字段、版本/schema 拒绝、旧格式升级或密钥域变化,必须由各 store 的迁移器读取、校验、转换并原子写入。 + +## 2. 证据边界 + +- 已做:当前 checkout 的静态代码检查、提交元数据/路径级检查、本机旧目录的名称、数量和大小检查。 +- 未做:未读取用户配置、会话、Token、Cookie、密钥或终端记录的实际内容。 +- 未做:未启动应用或开发服务器,未执行 UI/WebView 验证,未执行远程工作区、远控、Peer Device、Detached Dispatch 运行验证。 +- 未做:未运行迁移,未删除或移动任何旧数据。 + +表格中的“已核对”仅表示已找到当前代码 owner、路径或生命周期证据,不代表迁移实现已验证。 + +## 3. 根目录总览 + +| 状态 | 数据域 | 旧位置 | 新位置 | 优先级 | 总体建议 | +|---|---|---|---|---|---| +| 已核对 | 产品用户目录 | Windows `%APPDATA%/bitfun`;macOS `~/Library/Application Support/bitfun`;Linux `$XDG_CONFIG_HOME/bitfun`(通常 `~/.config/bitfun`) | 各平台同一 config root 下的 `openbitfun` | 分项判断 | 不可整目录复制;按 `config`、`data`、`skills` 等 store 迁移,排除日志、缓存和临时文件 | +| 已核对 | 产品 Home | `~/.bitfun` | `~/.openbitfun` | 分项判断 | 会话、工作区、记忆、身份、远控、dispatch 等分别迁移;排除锁、trace 和索引 | +| 已核对 | Tauri 应用数据 | `%APPDATA%/com.bitfun.desktop` | `%APPDATA%/com.openbitfun.desktop` | P1 | 窗口状态等按文件迁移,不复制异常恢复标志 | +| 已核对 | WebView2 用户数据 | `%LOCALAPPDATA%/com.bitfun.desktop/EBWebView` | `%LOCALAPPDATA%/com.openbitfun.desktop/EBWebView` | P1 | 不全是缓存;保留 Local Storage、IndexedDB;若承诺登录态则成组迁移 Cookies/Profile,排除浏览器缓存与运行锁 | +| 已核对 | Windows SSH 数据根 | `%LOCALAPPDATA%/BitFun/ssh` | `%LOCALAPPDATA%/OpenBitFun/ssh` | P0 | 连接、远程工作区、host key 信任和密码 vault 成组迁移;旧 connection ID 和旧单对象 workspace 格式需转换 | +| 已核对 | Git worktree / dispatch 外部实体 | 由旧注册表记录 | 由新注册表记录 | P0/P1 | 不得仅改 JSON 中的路径或直接搬目录;优先保留旧实体原位并让新注册表继续引用,或使用 Git-aware 专用迁移 | +| 已核对 | Desktop 开发 profile | `%APPDATA%/com.bitfun.desktop.dev`、`%LOCALAPPDATA%/com.bitfun.desktop.dev/EBWebView` | 对应 `com.openbitfun.desktop.dev` | P2(开发者) | 与正式 profile 完全隔离;只为确实使用过 dev build 的开发者按第 7 节规则迁移,不应混入普通用户升级 | +| 已核对 | Windows 安装与系统集成 | `%LOCALAPPDATA%/BitFun`、BitFun 注册表项/快捷方式/PATH | OpenBitFun 对应项 | P0(升级流程),非用户数据 | 旧安装器当前不会被新名称的检测器发现;需显式接管旧安装记录、安装新包并在成功后移除旧集成,详见第 8 节 | +| 已核对 | Android/iOS/HarmonyOS 应用沙箱 | 旧 package/bundle ID 的私有沙箱与安全存储域 | 新 package/bundle ID 的独立沙箱与安全存储域 | P0 | 包标识变化会让系统把新版视为另一个应用;数据库、身份和凭据不能靠文件名兼容读取,详见第 9 节 | +| 已核对 | Hosted MiniApp/Skin Market | `/srv/bitfun-*-market`、`/etc/bitfun-*-market` | `/srv/openbitfun-*-market`、`/etc/openbitfun-*-market` | P0(服务迁移) | 数据库、artifact、backup、secret env 和服务单元成组迁移;持久 JSON 与 Skin archive 还需结构化转换,详见 5.10 | +| 已核对 | 普通浏览器 origin storage | Market、Mobile Web、Design Lab 各自 origin 下的旧品牌 Local Storage/IndexedDB | 同 origin 下的新 key/DB | 分项判断 | Desktop 迁移器不得扫描外部浏览器 profile;同 origin 页面在各 store hydrate 前双读旧 key 并写新 key,跨 origin 则需站点级 bridge/export-import,详见第 7 节 | + +当前路径契约主要见: + +- `src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs` +- Desktop 的 Tauri identifier 配置 + +`dirs::config_dir()` 决定产品用户目录,所以不能把 Windows 路径硬编码成跨平台迁移规则。`~/.bitfun`/`~/.openbitfun` 则由用户 home 决定;`BITFUN_USER_ROOT`、`BITFUN_HOME` 等旧覆盖变量还可能让真实源位置偏离上述默认值,必须在创建任何新 store 之前按 5.9 处理。 + +## 4. `%APPDATA%/bitfun` 深入清单 + +### 4.1 配置与备份 + +| 状态 | 相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `config/app.json` | 全局产品配置;包含模型、运行时、语音等设置,部分字段可能直接包含 API key | P0 | 使用配置 owner 做 schema-aware 转换;更新产品标识并迁移 retired/renamed 字段,不能原样复制 | +| 已核对 | `config/hooks.json` | 用户级 native hook 配置 | P0 | 迁移并按当前 Codex hook contract 校验;保持启用/禁用和执行策略,不能只迁 hook 脚本不迁配置 | +| 已核对 | `config/external-sources.json` | 外部工具、MCP、subagent 等来源的选择、审批、冲突决策和已确认生态 | P0 | 完整迁移并校验 schema;这是安全/信任决策,不是发现缓存。原生候选 ID 的 `bitfun.desktop:*` / `bitfun.cli:*` 需改成 `openbitfun.desktop:*` / `openbitfun.cli:*`,并由当前 owner 重新计算包含候选 ID 的 native conflict key/fingerprint;不能只做 JSON 字符串替换 | +| 已核对 | `config/announcement-state.json` | 公告已读/展示状态 | P2 | 可迁移以避免升级后重复提示;失败不阻塞主迁移 | +| 已核对 | `config/installer-state.json` | 最近安装路径等安装器状态 | P2 | 可选迁移;路径失效时保留记录并明确降级 | +| 已核对 | `config/backups/`、`data/backups/` | 配置或工作区索引的旧备份 | P1(档案) | 保留为 legacy archive;若导入新 active backup 命名空间,必须先转换为当前格式 | +| 已核对 | 根部 `config.toml` | CLI 配置 | P0 | 迁移正式文件;不迁移同名锁文件。`ui.theme_id` 的 6 个内置值需逐项从 `bitfun-{cyber,dark,ink-night,light,midnight,tokyo-night}` 映射为 `openbitfun-*`;当前读取器不认识旧 ID,原样保留会静默退回基础主题。用户自定义/未知 ID 不做前缀猜改 | +| 已核对 | 根部 `agents/` | 用户级自定义 Agent 定义 | P0 | 完整迁移,目标已有同名定义时保留双方并报告冲突 | +| 已核对 | 根部 `update.lock` | CLI self-update 最长 1 小时的 install 互斥锁 | 不迁移 | 只对旧 updater 进程有效;升级前停止旧 updater,由新 CLI 在新根重新建锁 | +| 已核对 | 根部 `last-update-check` | 文件 mtime 控制自动检查频率,内容记录执行检查时的 CLI 版本 | 不迁移/P2 | 不复制;让新 CLI 按自己的版本和启动时间重新建立检查节奏,避免旧 mtime 延迟或扰乱新版本检查 | +| 已核对 | 根部 `update-last-error` | 后台更新失败的一次性诊断;下一次交互启动读取、打印后删除 | 不迁移 | 旧错误针对旧 binary/updater,不应在 OpenBitFun 首启中重放;源文件留在旧根供显式诊断 | + +`app.json` 当前加载会校验 OpenBitFun 产品标识、配置版本和 schema;`workspace_data.json` 也校验产品标识。因此这两个文件是“读取旧结构—转换—写新结构”,不是“复制后改一个目录名”。 + +### 4.2 核心持久化数据 + +| 状态 | 相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `data/workspace_data.json` | 工作区注册、选择和相关持久化信息 | P0 | 结构化转换,更新产品标识;对已移动/不存在路径保留记录并标记不可用,不静默删除 | +| 已核对 | `data/memories/memories.sqlite` | 结构化记忆数据库 | P0 | 在新 store 打开前用 SQLite backup API 迁移,或在旧进程完全退出后 checkpoint;不能只复制主 DB 而忽略 WAL | +| 已核对 | `data/agent-runtime/coordination.sqlite*` | Agent/subagent/background task 的 durable coordination 状态 | P0/P1 | 同样使用 SQLite 一致性迁移;`-shm` 不是用户数据,WAL 必须被正确合入 | +| 已核对 | `data/agent-runtime/ownership/` | 运行时所有权/互斥状态 | 不迁移 | 新进程重新建立,迁移会制造陈旧 owner | +| 已核对 | `data/agent-runtime/ipc-v/` | Shared Runtime discovery JSON、instance lock;Unix 还含 UDS socket,Windows endpoint 是 named pipe | 不迁移/敏感运行态 | record 含 PID、owner、endpoint 和连接 token。品牌 hash domain 与 pipe/socket 名都已变化;停旧 Runtime 后由新进程重建。不得把旧 token 或 discovery record 复制进新根,也不得把它误归入 `coordination.sqlite` 的 durable 状态 | +| 已核对 | `data/permissions/tool-permissions.sqlite` | 按项目保存的工具授权和有界审计记录 | P0 | SQLite 一致性迁移;旧数组式项目权限需由专用迁移器转换,不能只复制旧配置 | +| 已核对 | `data/cron/jobs.json` | 用户定时任务 | P0 | 旧 v1 当前代码明确要求显式迁移;转换到当前 schema 后原子写入 | +| 已核对 | `data/cron/backups/` | 定时任务恢复备份 | P1(档案) | 保留旧备份;如进入当前备份集合需先做格式转换 | +| 已核对 | `data/terminals/` | 持久化终端 transcript,包括命令、输出、cwd、退出码 | P1 | 迁移 `index.json` 和正式日志分段;排除 `index.json.tmp`,按敏感数据处理 | +| 已核对 | `data/token_usage/` | 模型使用量、Token 和费用统计历史 | P1 | 迁移 `model_stats.json` 与 `records/*.json` | +| 已核对 | `data/usage-data/` | 用户生成的 Insights JSON/HTML 报告 | P1/P2 | 建议迁移;虽可能从原始数据重建,但属于用户显式生成的历史产物 | +| 已核对 | `data/ssh_acp_capabilities.json` | 远程 ACP 能力探测快照 | 不迁移/P2 | 可重新探测;不应让旧快照覆盖当前协商结果 | + +### 4.3 用户创作、扩展和工作状态 + +| 状态 | 相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `data/frontend-workbench/state.json`、`state.previous.json` | 当前、上一版和 pending 前端修订状态 | P0/P1 | 必须迁移并保持引用闭包 | +| 已核对 | `data/frontend-workbench/drafts/` | 用户尚未 apply 的前端草稿 | P0 | 完整迁移 | +| 已核对 | `data/frontend-workbench/revisions/` | bundled 及用户/Agent 创建的前端修订 | P1 | 至少迁移 state 引用的 revision 和所有 `creative-*`;纯 bundled revision 可由安装包重新物化 | +| 已核对 | `data/frontend-workbench/.copy-*` | 复制/提交事务暂存 | 不迁移 | 陈旧事务目录 | +| 已核对 | `data/miniapps//` | MiniApp 源码、元数据、用户 storage、自定义、版本历史及编译产物 | P0/P1 | 正式 app 目录整体迁移最安全;`compiled.html` 可重建,但一并迁移可避免首启不可用 | +| 已核对 | `data/miniapps/.drafts/` | 进程内沙箱草稿 | 不迁移 | 当前启动会隔离并清理跨进程遗留 draft | +| 已核对 | `data/miniapps/.market-install-*` 等 | 市场安装、更新、回滚临时事务 | 不迁移 | 不能把未完成事务当成正式 MiniApp | +| 已核对 | 用户级/项目级 `hook-imports` | 外部 hook 导入索引、用户 enablement、经验证的托管 snapshot | P0/P1 | index 与 bundle 成组迁移,并重新校验 schema、路径及信任语义 | +| 已核对 | `data/rules/` | 用户级规则 | P0 | 完整迁移;用户内容不能被新版本默认内容覆盖 | +| 已核对 | `data/plugins/` | 用户安装的产品插件包 | P0 | 包、manifest、信任/启用相关 store 成组迁移;旧 `bitfun.plugin.json` 必须由 owner 校验后写成 `openbitfun.plugin.json`,详见 4.3.1 | +| 已核对 | `skills/` | 用户安装或产品附带的 skills | 分项判断 | 用户/第三方 skill 必须迁移;当前版本可重新生成且未被用户修改的 bundled system skill 可重建。配置中引用 skill 的稳定 key 也必须转换,详见 4.3.1 | + +#### 4.3.1 插件与 skill 的持久 identity + +插件和 skill 除了目录改址,还存在被配置、信任记录和历史 Turn 引用的品牌 identity: + +| 状态 | 旧值 → 新值 | 持久位置/作用 | 迁移方式与注意事项 | +|---|---|---|---| +| 已核对 | 每个 package 的 `bitfun.plugin.json` → `openbitfun.plugin.json` | `data/plugins//` 与工作区 `.bitfun/plugins//` 的托管插件 manifest | P0。当前 discovery 只查新文件名,直接复制目录会得到 `missing_manifest`。用当前 `PluginPackageManifest` parser 校验旧文件,确认 package 目录名、声明文件和 SHA-256 后,在目标 package 中以新文件名原子写入;不要盲目保留两份 active manifest | +| 已核对 | 旧 package canonical path → 新 package canonical path | `data/runtime/plugin-trust.json` 的 trust/activation record 保存 `packageId`、version、adapter、**绝对 canonical `sourcePath`** 和 content hash | P0(已批准/激活插件)。搬目录会改变 identity,原样复制 trust store 会使批准和 activation 失配。迁移器必须重新发现目标 package,仅在旧记录的 package/version/adapter/content hash 与目标验证结果一致时,把 `sourcePath` 重绑定到目标;目标已有 trust decision 时保留目标,不能借迁移扩大信任 | +| 已核对 | `bitfun://managed-plugins/...`、`bitfun://plugins/...` → `openbitfun://...` | adapter 生成的 source URI、tool target URI,并参与部分 runtime `plugin_id` hash;可能出现在 Session/dispatch transcript 的历史 tool/plugin diagnostics | 不能对 Session 文本全局替换。历史记录保留原 identity 供展示/审计;恢复仍需解析旧记录时由 plugin compatibility layer 接受旧 URI,并映射到已验证的新 package identity。新记录只写新 scheme | +| 已核对 | `bitfun.plugin.package.v1` / `*:bitfun.plugin` → `openbitfun.plugin.package.v1` / `*:openbitfun.plugin` | OpenCode adapter 生成的 `PluginManifestRef`,不是 `bitfun.plugin.json` 内部的数值 `schemaVersion: 1` | 历史诊断/Session record 双读旧值;不要把这段字符串误写进 package manifest,也不要改变 package manifest 的数值 schemaVersion | +| 已核对 | `user::bitfun::`、`project::bitfun::`、`user::bitfun-system::` → 对应 `openbitfun` key | `config/app.json` 中全局禁用与 Agent profile 的 enabled/disabled skill 列表,以及工作区 `.bitfun/config/agent_profiles.json`/`mode_skills.json` 的 project override | P0(用户选择)。按完整 key 的 slot 段转换,不能替换 skill 名或第三方 slot。当前 `normalize_skill_keys` 只 trim/dedupe,不兼容旧 key;不转换会让用户的启用/禁用选择静默失效 | +| 已核对 | `source_slot=bitfun\|bitfun-system`、`source_id=bitfun` → OpenBitFun 名称 | `SkillInfo`/Skill tool result 与历史 Session 中的来源说明;实际文件归属由目录和 discovery root 决定 | 历史 Turn 保持可读,不必为展示重写整份 Session。当前运行时重新 discovery 后写新 identity;任何以旧 `source_id/source_slot` 判断“产品 owned、可删除”的操作在兼容窗口内必须先 canonicalize,且仍禁止删除 built-in | + +插件目录还会出现在项目级 `.bitfun/plugins/`,因此同一 package 转换逻辑必须同时用于用户级与本地/远程 workspace。远程 workspace 的 manifest 与 trust/path identity 应在远端 filesystem owner 上处理,不能把远端 POSIX 路径改写成控制端路径。 + +### 4.4 浏览器自动化与可重建数据 + +| 状态 | 相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `data/browser-control//` | 专用 Chromium user-data-dir,可能包含 Cookie、登录态、Local State、Preferences | P1/可选 | 若承诺保留自动化浏览器登录态,应迁移;`Local State`、Profile、Cookies 必须成组,并要求浏览器及旧版应用已退出 | +| 已核对 | Chromium `Cache`、`Code Cache`、`GPUCache`、shader cache、Crashpad、`Singleton*`、`DevToolsActivePort` | 缓存、崩溃或运行锁 | 不迁移 | 新版本重建 | +| 已核对 | `runtimes/` | OpenBitFun 管理的 Node/Python/Office 等运行时 | 不迁移/P2 | 属于可重新供应的产品依赖;若为节省下载而复用,必须验证版本、完整性和内部绝对路径,不能盲拷 | +| 已核对 | `data/models/speech/` | 本地语音模型资源 | P2 | 可重新下载;可做校验后硬链接/复制的优化迁移,但不能让模型资源阻塞 P0 数据迁移 | +| 已核对 | `cache/model-downloads/speech/` | 未完成或可重试的语音模型下载 | 不迁移 | 让新版本重新下载 | +| 已核对 | `data/sdk-host/temp/scripts/` | SDK Host 临时脚本 | 不迁移 | 名称和生命周期均表明是临时执行产物 | +| 已核对 | 根部 `temp/scripts/` | Terminal shell integration 生成脚本 | 不迁移 | 新版本按当前 shell/runtime 重新生成;旧脚本可能嵌入旧可执行文件或绝对路径,不能复用 | +| 已核对 | `cache/`、`temp/` | 可重建缓存与临时文件 | 不迁移 | 不复制 | +| 已核对 | `logs/`、`cli-logs/` | 应用与 CLI 日志 | 不迁移 | 用户已明确无需迁移;如需诊断可把旧目录留作只读历史,不纳入新活动日志目录 | + +### 4.5 凭据和安全敏感数据 + +| 状态 | 相对路径/外部存储 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `data/subscription_auth.json` | 订阅账户的 v2 metadata:provider、过期时间、account metadata、secret set/chunk 计数,以及注销/并发保护用的 `provider_revisions` tombstone | P0 | 结构化迁移并保留 provider revision;相同 provider 两侧都有记录时目标 credential 优先,但 revision 取较大值。不能只复制 metadata,因为 secret 在外部 vault;也不能漏掉已注销 provider 的 revision,否则旧 refresh 可能复活凭据 | +| 已核对 | Windows/Linux secret service `openbitfun.bitfun.subscription-auth.v1` | 更名前的订阅 secret 域;v2 entry 名为 `/v2///`,更旧格式为 provider 单 entry | P0 | 从旧 service 逐 chunk 读取、重组并校验,再以新随机 set ID 写入 `openbitfun.subscription-auth.v1`;全部新 secret 写入成功后才提交新 metadata。旧单 entry 是组合 JSON,需复用提交前已有的 legacy importer;不得在日志中输出 entry 内容 | +| 已核对 | macOS `data/.subscription_auth_vault.key` + `data/subscription_auth_vault.json` | prompt-free 文件凭据 vault | P0 | 用旧 key 解密旧 vault,再通过新 owner 按 provider 写入目标 vault并提交 metadata;不能在目标已有数据时复制两文件覆盖,也不能只迁其中一个 | +| 已核对 | `data/subscription_auth.cleanup.json` | 未完成的 vault secret 清理 journal | P0(安全状态) | 记录的是旧 vault service 中待清理 entry,不能原样复制后让新 service 执行。源保持只读;迁移 active credential 后把旧域的待清理项列为显式后续清理,且不能删除仍被旧 metadata 引用的 entry | +| 已核对 | `data/subscription_auth.bak` | Windows metadata replace 的回滚文件,可能仍含旧明文 credential | 条件恢复/高敏感 | 先按旧 owner 的事务规则判断 canonical 缺失时是否恢复,再开始跨目录迁移;canonical 已存在时不作为第二份账户合并。遗留 backup 必须按 secret 文件保护,不能普通归档或输出内容 | +| 已核对 | `data/subscription_auth.lock`、`subscription_auth--refresh.lock`、`*.tmp` | metadata transaction/refresh lease 和写入暂存 | 不迁移 | 新目录重新创建锁;旧锁 inode/PID 生命周期无效。临时文件只保留在旧根供诊断,不导入 active store | +| 已核对 | WebSearch `credentials.key` + `credentials.json` | Web 搜索凭据 | P0 | key 与密文成组迁移,校验后原子提交 | +| 已核对 | `.mcp_oauth_vault.key` + `mcp_oauth_vault.json` | MCP OAuth 凭据 | P0 | key 与 vault 成组迁移 | +| 已核对 | `review-platform-tokens.json` | GitLab/GitCode token 明文 JSON store | P0 | 按高敏感明文数据迁移,限制文件权限并原子写入;GitHub token 由 `gh` 管理,不在此文件 | +| 已核对 | `config/app.json` 内 API key | 模型/语音等服务凭据 | P0 | 随结构化配置迁移,迁移日志不得输出值 | +| 已核对 | Windows/Linux keyring service `openbitfun.bitfun.miniapp-market.v1`,entry `github-oauth` | 更名前 MiniApp Market GitHub OAuth 的 access/refresh token 及各自过期时间 | P0/高敏感 | 读取完整旧 payload、反序列化并校验后写入新 service `openbitfun.miniapp-market.v1` 的同名 entry。目标已有完整凭据时保留目标,不逐字段拼接;任何日志不得输出 token | +| 已核对 | macOS `data/.market_credentials_vault.key` + `data/market_credentials_vault.json` | 同一 MiniApp Market OAuth payload 的文件凭据 vault | P0/高敏感 | 旧文件实际位于旧产品 config root 的 `data/`。key 与密文 vault 必须成组读取,再通过新 owner 写入目标;不能只复制其中一个,也不能覆盖已有目标 vault | + +### 4.6 未识别文件、历史目录与手工副本 + +迁移器必须对旧根做一次“已知项减法”盘点:不能因为某个路径没有当前代码 owner 就静默删除,也不能把未知文件直接放进新活动配置目录。本机旧目录的只读名称检查发现了下表中的实例;没有读取文件内容。 + +| 状态 | 相对路径/模式 | 观察与作用判断 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对目录元数据 | 根部未识别的普通文件(本机存在一个 HTML 和一个 PDF) | 很可能是用户或工具误放到产品根的文件;不是当前 active store | P0(保全) | 保留在旧根,或复制到新根之外的 `legacy-unclassified` 只读档案;在迁移报告中逐项列出相对路径。不得丢弃,也不得自动打开或解释内容 | +| 已核对目录元数据 | `config/app copy.json`、`config/app user copy.json` 等手工副本 | 非当前配置 owner 使用的正式文件,可能含用户调试副本和 API key | P0/P1(档案) | 不导入为 `config/app.json`,不参与“新旧配置谁覆盖谁”的自动裁决;作为敏感 legacy archive 保留并在报告中提示用户手工处理 | +| 已核对目录元数据 | `data/backups/*_workspace_data.json` | 旧 workspace registry 的时间戳备份;当前 active owner 只使用 `data/workspace_data.json` | P1(档案) | 保留原始旧格式。不能直接放入当前恢复集合;只有用户选择恢复某一份时,才通过与 active registry 相同的 schema-aware 转换器导入 | +| 已核对目录元数据 | 根部 `backups/` | 本机实例为空,当前代码未找到该根级目录的活动 owner | 不迁移;非空则档案 | 空目录不创建;若其他安装中非空,按未知文件保留并报告,不与 `config/backups/` 或 `data/backups/` 混合 | +| 已核对目录元数据 | `agents/templates/` | 本机实例为空,当前 checkout 未找到活动 owner | 不迁移;非空则 P0/P1 档案 | 空目录不创建;非空内容先作为用户 Agent 模板候选保全,待格式识别后再由 Agent store 显式导入 | +| 已核对目录元数据 | `data/history/`、`data/snippets/`、`data/templates/`、根部 `workspaces/` | 本机实例均为空,当前 checkout 未找到活动 owner | 不迁移;非空则档案 | 空目录不创建;其他用户机器上若非空,不能据本机样本推断其可删除,应列入 `legacy-unclassified` | +| 已核对目录元数据 | `data/lsp-plugins/` | 本机实例为空;LSP 系统已在 `69bf1c270` 退休 | 不迁移到 active store;非空则档案 | 不恢复为当前产品能力。非空的插件包或用户配置保留在 legacy archive,避免升级过程替用户删除内容 | +| 部分核对 | `data/telemetry/uid` | 本机存在 36-byte 文件;当前 checkout 和更名前父提交均未找到该路径的活动 owner,不能确认是否仍有服务端身份语义 | P2/档案,不导入 active store | 默认原地保留或归档,不把旧 UID 注入当前 telemetry/设备身份。若后续找到历史 owner 或合规保留要求,再增加专用转换;它不能替代 `device_identity.json` | + +实现时应维护显式的“已消费路径集合”。迁移结束后,对旧根剩余项只做名称、类型、大小和错误类别级报告;不要通过扩展名猜测并导入,也不要读取未知文件来生成日志摘要。 + +## 5. `~/.bitfun` 深入清单 + +### 5.1 顶层数据 + +| 状态 | 旧相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `projects/` | 本地工作区的运行时数据 | P0/P1 | 按工作区逐项迁移,详见 5.2 | +| 已核对 | `remote_ssh/` | 远程 SSH 工作区在本机的运行时镜像 | P0/P1 | 保持 `/` 相对结构,不能用 Windows path 语义重新解释 | +| 已核对 | `personal_assistant/` | 助理工作空间及多 workspace 变体 | P0 | 迁移 `workspace`、`workspace-`;同时发现更老的顶层 `workspace` / `workspace-*` | +| 已核对 | `memories/` | 记忆生成、归并和工作文件 | P0 | 整体迁移;它与 `%APPDATA%/bitfun/data/memories/memories.sqlite` 是不同数据域,均需保留 | +| 已核对 | `runtime-events/` | 每 Session 一份 JSONL 的在途流事件,用于崩溃/重启后恢复尚未完整写入 Session 的 Turn | P0 | 迁移能与已迁移 Session 关联的文件;首版不要主动删除无法关联或疑似陈旧文件 | +| 已核对 | `device_identity.json` | Remote Connect/设备身份 | P0 | 必须保留有效的 32 位十六进制 `device_id`;该 ID 可能已被账户登录流程改写为服务端绑定 ID,不能依赖主机名/MAC 重新生成 | +| 已核对 | `dispatch/` | Detached Dispatch 的 target job、controller outbound、Git 交付和崩溃恢复状态 | 分项判断 | 详见 5.4;活动任务和未完成同步必须保留,锁/PID 不迁移 | +| 已核对 | `worktrees/` | 产品管理的 Git worktree 实体根 | P0/P1 | 详见 5.5;默认不搬实体,迁移项目运行时内的注册表并保留旧绝对路径 | +| 已核对 | `relay-deploy/`、`relay-src/`、`docker-config*` | Relay 部署选择、源码/自定义和 Docker client config | 分项判断 | 详见 5.6 | +| 已核对 | `account_session.enc` + `account_session.key` | Remote Connect 登录 token、user ID、账户 master key、relay URL 和可选 device ID | P0 | 直接复制后新代码无法解密。旧 v2 key 是 `SHA256(legacy-machine-key + "\|BitFun::session_store::v2\|" + local-secret)`,更老 v1 是 machine-bound `BitFun::session_store::v1`;迁移器依次尝试旧格式,校验 payload 后以当前 `openbitfun::session_store::v1` 域重新加密。目标已有不同账户/relay 时不覆盖 | +| 已核对 | `account_hint.json` | 登录表单预填的 username + relay URL,不含 password/master key | P1 | 结构化复制;目标非空时保留目标。它不是登录凭据,不能在 account session 解密失败时制造“已登录”状态 | +| 已核对 | `account_sync/.json` | Session 云同步 pull cursor 和每 Session 已上传内容 hash | P1 | 仅在已确认是同一 user + relay 账户时合并:`last_session_since` 取较大值,`uploaded_hashes` 做并集且目标同 ID 值优先。损坏文件留存并报告,不能按当前 loader 的 default 行为静默归零 | +| 已核对 | `account_sync/.settings.json` | 设置同步的 `(version, hash)` 游标 | P1 | 只在同一 user + relay 下合并,并把 version/hash 当成不可拆分的一对;选择较高 version 对应的整对值。`*.tmp` 不迁移 | +| 已核对 | `remote_connect_persistence.json` | Feishu/Telegram/Weixin bot 连接、明文 bot secret、聊天工作区/助理/Session 选择、配对状态、表单值和 verbose 偏好 | P0 | 按高敏感 owner-only JSON store 迁移;旧 bare-string `current_workspace` 通过当前兼容反序列化升级。按 `bot_type` 合并且目标 connection 优先,旧值只填目标空缺;解析失败不得像当前普通 loader 那样降为默认空数据 | +| 已核对 | `bot_connections.json` | `remote_connect_persistence.json` 之前的同结构 bot store | P0(条件) | 仅当旧 canonical 文件不存在且不存在 `remote_connect_persistence.json.bak` 时作为来源;这是更名前 loader 的既有 fail-closed 顺序。成功导入后写当前 canonical store,旧文件保持只读 | +| 已核对 | `remote_connect_persistence.json.bak`、写入临时文件 | Windows replace 事务的回滚状态 | 不自动迁移为 active data | 若 canonical 缺失而 `.bak` 存在,表示可能停在清除/替换中;禁止回退读取 `bot_connections.json` 或擅自恢复旧账户上下文。保留并报告为需人工/owner 恢复的事务异常 | +| 已核对 | `weixin/_get_updates_buf.txt` | Weixin long-poll 增量游标 | P1(活动 bot) | 对 `remote_connect_persistence.json` 引用的 bot account ID 迁移,避免重复消费或错过恢复边界;目标已有值时由 Weixin owner 比较/验证,不按字符串大小猜测新旧 | +| 已核对 | `weixin/_context_tokens.json` | peer ID → Weixin context token;没有 token 时在下一条入站消息前不能正常回复/发送 typing | P0/P1(活动 bot) | 作为敏感 token map 迁移,按 peer ID 合并且目标值优先,文件权限收紧;不得记录 peer/token。无对应 bot account 的孤立文件保留为 legacy archive | + +`BotChatState` 的 pending action、过期时间、delegated account token/master key、活动远端设备对象等字段带 `serde(skip)`,本来就只存在于进程内,不属于迁移对象。持久化的 `account_remote_context` 标志则必须保留,因为它防止重启后把远端路径/Session 错当成本机上下文。 + +### 5.2 工作区运行时目录:`projects/` 与 `remote_ssh/` + +每个工作区不能只迁 `turns/`。一个完整 Session 可能包含 `metadata.json`、`state.json`、`turns/`、`turn-catalog.json`、`prompt_cache.json`、`token-anchors.json`、`snapshots/`、`artifacts/`、`tool-results/` 和 `evidence-ledger.json`。 + +| 状态 | 工作区内相对项 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `sessions/` | 会话 metadata、turn、状态、缓存重放信息、产物和证据 | P0 | 以完整会话为迁移单元,保留内部 ID 和相对结构;排除会话内 `request-traces/` 与运行锁 | +| 已核对 | `plans/` | 会话/工作流计划 | P1 | 随工作区迁移 | +| 已核对 | `snapshots/` | 工作区或会话快照 | P1 | 随工作区迁移;保持 Session/Turn 引用关系 | +| 已核对 | `config/isolation_status.json` | 工作区隔离状态 | P1 | 谨慎迁移;目标路径或 worktree 失效时显式降级,不能制造“已隔离”的假状态 | +| 已核对 | 项目级持久化 store(如 `hook-imports`) | 项目范围的集成配置和信任状态 | P0/P1 | 按 owning store 迁移和校验 | +| 已核对 | `request-traces/` | 请求诊断 trace | 不迁移 | 诊断数据,不属于会话事实 | +| 已核对 | `.session-write-locks/`、`locks/`、`*.lock` | 进程互斥和写锁 | 不迁移 | 新进程重新建立 | +| 已核对 | `product-search-v1.sqlite`、`search/` | 可重建搜索 sidecar/index | 不迁移 | 让新版本重建,避免旧 schema 或绝对路径污染 | +| 已核对 | 空的旧布局 `checkpoints/`、`diffs/`、`local/` | 历史布局遗留 | 不迁移 | 若实际检测到非空且代码 owner 不明,应保留在 legacy archive 并报告,不直接丢弃 | +| 部分核对 | 旧 `memory/` | 旧工作区级记忆布局 | 待定 | 不主动删除;先做版本识别,再导入当前记忆 owner 或留作 legacy archive | + +### 5.3 任意工作区内的 `.bitfun/` + +不能扫描整块磁盘来寻找工作区。应从 `workspace_data.json`、助理工作空间、远程工作区注册记录和 dispatch/worktree 注册表枚举已知工作区,再检查其根部的旧产品目录。 + +| 状态 | `.bitfun/` 内相对项 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `config/agent_profiles.json`、`tool_permissions.json`、`mode_skills.json`、`agent_subagents.json`、`hooks.json` | 项目级 Agent、权限、skill、subagent、hook 配置 | P0 | 迁移到工作区 `.openbitfun/config`,必要时执行各配置 schema 转换 | +| 已核对 | `agents/`、`rules/`、`plugins/`、可能的 `skills/` | 用户编写或安装的项目扩展内容 | P0 | 保持文件和目录结构;不得被新版本默认内容覆盖 | +| 已核对 | `search/flashgrep-index` | 工作区搜索索引 | 不迁移 | 可重建 | +| 已核对 | `computer_use_debug/` | Computer Use screenshot/OCR 调试图像;默认助理工作空间下实际形成嵌套的 `workspace/.bitfun/computer_use_debug/` | 不迁移/敏感诊断 | 新版本不需要这些 JPEG 恢复任何运行状态;文件名还可能带 OCR 查询片段。源目录保持只读或由用户另行归档,迁移报告只记录目录级数量/大小,不输出文件名和图像内容 | +| 已核对 | `bin/`、`tmp/` | 下载/运行时工具、临时文件 | 不迁移 | 新版本重建或按需重新下载 | + +### 5.4 Detached Dispatch + +同一个 `~/.bitfun/dispatch` 根下有两种角色的数据: + +- target 侧由 CLI `DispatchStore` 持有真正的 job、事件流、permission/message/turn mailbox 和目标 Git checkout; +- controller 侧 `outbound/` 只是提交到其他进程后的 durable observer 索引、恢复 journal 和传输产物。 + +#### 5.4.1 Target 侧 + +| 状态 | 相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `jobs//job.json` | 提交请求、protocol version、intent hash、创建时间、标题;也是初始化完成的 commit marker | P0 | 与该 job 的 state/events/mailbox 成组导入;检查旧 `productId`/`dataNamespace` 和协议版本,不能仅复制 | +| 已核对 | `jobs//state` | queued/running/terminal、turn、取消和错误状态 | P0 | 与 job 原子迁移;旧 `running` 不能凭 PID 直接恢复,应由 owner 的 crash recovery 判定 | +| 已核对 | `events.ndjson` + `events.meta.json` | 有界追加事件流、cursor base、截断/省略事实 | P0 | 成组迁移,保持 byte cursor 语义;不能重排或只复制事件文件 | +| 已核对 | `permissions/{pending,answers,resolved}/` | 远程可回答权限 mailbox | P0 | 完整迁移;pending 权限是远程解除阻塞所必需的数据 | +| 已核对 | `messages/{pending,consumed}/`、`turns/{pending,consumed}/` | 在途 steering 消息和后续 Turn 的幂等 mailbox | P0 | 完整迁移并保留 message/turn ID | +| 已核对 | `job.pid`、`preparing`、`.lock`、`.events.lock`、`.spawn.lock`、`.worker.lock` | 旧进程 PID、spawn/worker/写入互斥和中间标志 | 不迁移 | 升级后进程身份和锁均失效;让 current owner 重建/恢复 | +| 已核对 | `.retention-gc`、`.retention-gc.lock`、`workspaces/.*.lock`、`repos/.*.lock` | GC 时间戳和操作锁 | 不迁移 | 新进程重新调度 GC 并建立锁 | +| 已核对 | `workspaces//provision.json` | job 与 repoKey、目标 checkout、bundle 状态之间的 durable binding | P0(活动/待同步 job) | 必须和 job、repo、worktree 一致处理;其中绝对路径不能在复制后单独替换 | +| 部分核对 | `workspaces//` 其他 bundle/upload/sync 文件 | 传输断点和同步中的产物 | P0/P1(仅未完成操作) | 仅对活动 preparation/upload/sync 保留;已完成 job 的纯 staging 可由 owner 清理 | +| 已核对 | `repos//` | target 共享 bare repository;虽称 cache,但可能仍承载活动 job 或尚未拉回 controller 的提交 | P0/P1 | 活动/近期 job 引用时不能丢;不要脱离 `worktrees` 单独搬移 | +| 已核对 | `worktrees//-` | target 每 job checkout,可能含尚未同步回 controller 的用户工作结果 | P0/P1 | 不应视作缓存。优先保留旧根原位并兼容引用;若必须迁移,使用 Git-aware 迁移并修复 bare repo/worktree 双向 metadata | + +target terminal job、worktree 和事件目前按 30 天保留,共享 bare repo 也有独立的 30 天 last-used 保留策略。品牌迁移不能把“会被 retention 清理”误解成“升级时可直接丢弃”:近期 terminal job 仍需要被观察和同步结果。 + +#### 5.4.2 Controller `outbound/` + +| 状态 | 相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `outbound/.json` | durable observer 索引;保存 target/session、cursor、状态、源工作区,以及 baseline worktree ID/path/claim | P0 | 结构化迁移;绝对路径和 worktree claim 必须与 5.5 的决定一致,不能整目录覆盖 | +| 已核对 | `outbound/.preparations/.json` | 提交确认前的 owner-only crash journal 和 setup audit | P0(未完成提交) | 保留未过期/仍有关联的记录;先恢复或核对 baseline claim,再进入正常 reconciliation | +| 已核对 | `outbound/.preparations/.run` 等锁 | preparation 运行锁 | 不迁移 | 新进程重新建立 | +| 已核对 | `outbound/.transcripts/.json` | renderer observer transcript 与 cursor/completeness 配对缓存 | P1 | 建议迁移。它可从 target 重放,但 target 事件可能已截断;丢失后不一定能恢复完整可见历史 | +| 已核对 | `outbound/.bundles/` | controller 上传给 target 的 base Git bundle staging | P1(未完成上传) | 只为未完成 preparation/upload 保留;能从仍存在的 baseline 安全重建时可丢弃 | +| 已核对 | `outbound/.results/` | 从 target 拉回、尚未成功 fetch/fast-forward 的 result bundle 及摘要 | P0(未完成同步) | 在 baseline 已确认接收前必须保留;成功接收后属于可删除传输产物 | + +#### 5.4.3 Dispatch 分支名 + +品牌提交把默认 `app.worktrees.branchPrefix` 从 `bitfun/` 改为 `openbitfun/`,因此新建 dispatch baseline 的默认分支也从 `bitfun/dispatch/` 变成 `openbitfun/dispatch/`。这里的分支名不是展示文案:它同时存在于实际 Git ref、managed worktree 注册表、controller `outbound/.json`、preparation journal,以及 target 的 provision/job 状态中。 + +迁移时应区分“未来默认值”和“既有实体”: + +1. 只有 `config/app.json` 中的 `app.worktrees.branchPrefix` **精确等于旧默认值** `bitfun/` 时,才迁为 `openbitfun/`;用户自定义前缀必须原样保留。 +2. 已存在或仍活动的 `bitfun/dispatch/*` 分支及其所有引用默认保持原名。新代码应按记录中的真实分支操作,而不能根据当前默认值重新推导。 +3. 不能只改 JSON 中的 `branch`。那会让记录与实际 ref 分离,并可能使 baseline guard、result fetch/fast-forward 或 retention claim 失效。 +4. 如果产品明确要求重命名既有分支,必须作为 Git-aware 原子事务:确认 checkout/ref/dirty 状态,重命名真实分支并同步 worktree registry、outbound、preparation、target provision/job 等全部引用;任一步失败都保留旧分支与可恢复 journal。 + +### 5.5 普通 managed worktree + +普通 worktree 的实体默认位于 `~/.bitfun/worktrees//-`,但注册表 `worktrees.json` 位于对应项目的运行时 `config` 目录中。注册表保存 `projectWorkspacePath`、每个 worktree 的绝对 `path`、branch/base/lifecycle、幂等 receipt,以及 `dispatch:` retention claim。 + +Git 的 common dir `worktrees/*` admin metadata 和 linked checkout 内的 `.git` 文件也包含路径关系。因此建议: + +1. **迁移项目 runtime 中的 `worktrees.json`,但默认不搬 `~/.bitfun/worktrees` 实体。** +2. 新注册表继续引用旧实体的绝对路径;新建 worktree 使用新的 `~/.openbitfun/worktrees` 根。 +3. 保留并验证 dispatch claim,避免升级后自动清掉仍作为 dispatch baseline 的 worktree。 +4. 若产品决定实体也必须改路径,应走单独 Git-aware 事务:确认 clean/dirty/branch/claim 状态,使用 Git worktree 操作或 repair 更新两侧 metadata,再更新工作区索引、注册表和所有 outbound record。普通文件复制后字符串替换不可接受。 +5. 用户在 `app.worktrees.root_path` 中显式配置的自定义绝对根不应改写;只有等价于旧产品默认值的配置才可按迁移策略处理。 + +### 5.6 Relay 自部署与远端 Docker 数据 + +这一组数据位于执行 Relay 部署的 SSH 目标机,不会因为本机 Desktop 目录迁移而自动迁走。所有运行过旧 CLI/Relay 自部署的主机都要独立发现和处理。 + +| 状态 | 旧位置/对象 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `~/.bitfun/relay-deploy/relay.port` | Relay 监听端口选择 | P1 | 迁移到新目录,避免升级后回到默认端口导致健康检查或端口冲突 | +| 已核对 | `~/.bitfun/relay-deploy/relay.mirror-mode` | 镜像路线选择 | P2 | 可迁移用户选择;不存在则重新 auto 探测 | +| 已核对 | `relay-deploy/*.sh`、`*.pid`、`*.driver.pid`、`*.preparing`、`*.log` | 生成脚本、运行 PID/准备标志和部署日志 | 不迁移 | 升级不得接管旧 PID;检测到仍在运行的部署任务时应停止迁移并要求其结束/取消,而不是复制状态 | +| 已核对 | `relay-deploy/import-*.json` | 一次性账户导入文件,正常路径会立即删除 | 不迁移 | 若遗留,按高敏感失败产物告警并留在旧目录,不复制到新活动目录 | +| 已核对 | `~/.bitfun/docker-config`、`docker-config-` | Relay 自部署专用 Docker client config,可能含 registry auth 和用户 Docker 设置 | P0/P1 | 迁移 `config.json` 及未知用户文件,保留 0700/所有权;不要输出 auth。多个 uid 目录分别处理 | +| 部分核对 | `~/.bitfun/relay-src` | source-mode Relay 部署 checkout、`.env` 及可能的用户修改 | P1 | 不能整体当缓存。`.env`、Caddy/部署自定义和 Git dirty 文件必须保留;纯可验证 clean checkout 可重新 clone | +| 已核对 | Docker container `bitfun-relay` → `openbitfun-relay` | 正在运行的旧 Relay 服务 | P0(运行迁移) | 新部署前受控停止/暂存旧容器,验证新容器健康后再移除;否则旧容器会占用端口,新部署也看不到它 | +| 已核对 | Docker volume `relay-server_relay-db` | Relay 账户、设备、会话/路由等数据库所在 volume | P0 | volume 名未变,但容器内 DB 文件从 `/app/data/bitfun_relay.db` 改为 `openbitfun_relay.db`;必须在旧容器停止/SQLite 一致性确认后迁移 DB,不能让新容器创建空库 | +| 已核对 | Docker volume `relay-server_room-web` | Remote room Web 资产和 OpenBitFun Pages 数据 | P0/P1 | volume 名和 mount path 未变,应原地保留;不得作为镜像缓存删除 | +| 已核对 | 旧 Relay image 与 brand label | 可重新拉取的容器镜像/标记 | 不迁移 | 新 Relay 验证健康后再由维护流程清理旧镜像 | + +若用户通过 Compose source mode 部署,volume 的实际前缀由 Compose project 决定,不能假定总是 `relay-server_*`;迁移器应从旧容器 inspect 的 mount 列表发现真实 volume。 + +### 5.7 CLI daemon 与远端安装状态 + +| 状态 | 旧位置/对象 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `~/.bitfun/cli_daemon.pid` | CLI account-device daemon 的进程 PID | 不迁移 | PID 只对旧进程有效;受控停止旧 daemon 后由新 daemon 重建 | +| 已核对 | Linux `$XDG_CONFIG_HOME/systemd/user/bitfun-cli-daemon.service` | 旧 CLI daemon 自启动单元 | P0(系统集成) | stop/disable 旧 unit,安装并启动 `openbitfun-cli-daemon.service`;不能简单复制,因为 ExecStart 和 unit 名均变化 | +| 已核对 | macOS `~/Library/LaunchAgents/com.bitfun.cli.daemon.plist` | 旧 CLI daemon LaunchAgent | P0(系统集成) | bootstrap/bootout 有序切换到 `com.openbitfun.cli.daemon`,并更新可执行文件路径 | +| 已核对 | `~/.bitfun/dispatch/install/` | SSH 自动安装 CLI 的 archive、脚本和 staging | 不迁移 | 安装传输产物可由当前签名 release 重新供应 | +| 已核对 | 旧 `bitfun` CLI 可执行文件/软链接 | 旧 daemon、交互命令和自升级入口 | P0(升级入口),不属于用户数据 | 当前 Linux self-update 的 `install_archive` 明确要求当前文件名为 `openbitfun`,并从 archive 取同名 binary;以 `bitfun` 文件名运行会被拒绝。品牌切换必须由外部 bootstrap/package manager 或专门的旧版 bridge 安装新 binary,再切换 service/PATH;验证新 CLI 与 daemon 后再决定保留 `bitfun` compatibility shim 或清理。Windows/macOS 当前 self-update 本来不支持,分别走 installer/package manager | + +旧 daemon 与新 daemon 不能长期并行:同机进程共享 `device_id`,Remote Connect 按该 ID 路由,后连接者会抢占路由。升级顺序必须是“停旧服务 → 迁移身份/账户 session → 安装新服务 → 验证在线”。 + +### 5.8 持久 metadata 与跨版本 wire 保留键 + +品牌提交还改名了若干双下划线保留键。它们不一定各自形成独立文件,但会进入持久 Turn/缓存,或跨 Desktop、CLI、Runtime、Peer 的事件 envelope。当前 reader 都只读取新名称,因此必须在兼容层双读,而不能指望目录复制自动修复。 + +| 状态 | 旧键 → 新键 | 出现位置与作用 | 迁移/兼容结论 | +|---|---|---|---| +| 已核对 | `__bitfunOptimisticDispatchJobId` → `__openbitfunOptimisticDispatchJobId` | `DialogTurn.userMessage.metadata` 中的 optimistic-turn adoption 标记;会随 Session Turn、dispatch transcript cache 等完整 Turn 持久化 | P0(含仍 pending 的 Turn)。读取与 strip 阶段同时接受旧/新键,写回时规范化为新键。当前只读新键,旧 optimistic Turn 可能无法被 target 的正式 Turn 接管,造成可见重复 Turn | +| 已核对 | `__bitfunRuntimeStreamId`、`__bitfunRuntimeEventCursor` → 对应 `__openbitfun*` | Runtime 投递给 Desktop/Web/Peer 的事件 payload 游标,用于检测跨进程 stream 与事件 gap | wire 兼容 P0。接收端在支持旧 peer/runtime 的窗口内双读并同时剥离两组键;发送端应按协商能力选择名称或在过渡期双写。否则旧键会被当作无游标事件,gap fencing/replay 语义失效 | +| 已核对 | `__bitfunSourceDeviceId`、`__bitfunSourcePayload` → 对应 `__openbitfun*` | Desktop 给 peer-originated event 加的来源设备和非对象 payload wrapper;通常只存在于 Remote Connect/Peer 事件 envelope | wire 兼容 P0,不作为独立磁盘 store 搬迁。混合版本双读/双剥离;否则 peer 事件可能被误判为本地未标记事件,或把 wrapper 本身交给业务消费者,造成跨设备事件串线 | +| 已核对 | Runtime identity hash domain `bitfun-agent-runtime-instance-v2` → `openbitfun-agent-runtime-instance-v2` | Shared Runtime 根据产品、channel、用户数据根、protocol、workspace 计算的实例 hash;hash 命名 discovery/lock,并派生 named pipe/UDS 名 | 不迁移。它是活进程 ownership/discovery 边界,不是 Session identity;新版本应创建新实例。升级器需先停旧 Shared Runtime,不能为了“复用”而伪造新 hash 指向旧 endpoint | +| 已核对 | CLI stdio Host identity `bitfun-cli-server-host` → `openbitfun-cli-server-host` | `openbitfun server` 注入的进程内 `AppServerHostPolicy` identity | 当前无独立落盘对象,不迁移。它不是 `device_identity.json`,也不应据此改 Session/workspace ID;若外围客户端另有持久 host allowlist,那属于该客户端自己的跨版本兼容数据,需同时接受旧/新 identity 后再规范化 | +| 已核对 | `bitfun://runtime/...`、`bitfun://current-session/...` → `openbitfun://...` | tool path、tool result、compression transcript、session reference 等会把逻辑路径写入 Session Turn 和相关持久记录 | P0(历史记录仍需恢复/解析时)。不能对 Session JSON 全局替换;path parser/resolver 在兼容期双读旧 scheme,完成 workspace/session 边界和 traversal 校验后只在运行时 canonicalize。新记录只写新 scheme;当前 `tool-contracts` parser 仅声明新前缀 | +| 已核对 | `bitfun://payloads/...`、`bitfun://artifacts/...`、`bitfun://logs/...` → `openbitfun://...` | adapter/runtime diagnostics 中的 payload、artifact 和 log URI,可能随 tool/plugin 结果持久化 | 保留原始历史 URI用于展示与审计;需要 dereference 的 owner 双读并映射到已验证对象,不按字符串前缀直接拼本地路径。新记录只写新 scheme | +| 已核对 | `bitfun://pair?...` → `openbitfun://pair?...` | Remote Connect 二维码/深链,可能被用户保存、转发,或在混合版本手机与 Desktop 间传递 | wire/deep-link 兼容 P0,不属于磁盘目录搬迁。新移动端在兼容窗口内接受旧链接并按字段校验;不能只接受新 scheme 后要求用户重新生成仍有效的旧链接 | + +`~/.bitfun/runtime-events/*.jsonl` 不需要为上述游标键做全局文本替换:其落盘 record 的稳定字段是 `streamId`、`cursor`、`event`。该目录仍按 5.1 作为 Session 恢复数据迁移,保留原始 JSONL 顺序与字段即可。保留键兼容应放在事件 envelope owner,而不是数据目录迁移器中。 + +### 5.9 持久环境变量与启动配置 + +进程环境变量本身不是目录中的文件,但用户或部署者会把它们持久化在 Windows `HKCU\Environment`/系统环境、PowerShell profile、`.bashrc`/`.zshrc`/`.profile`、systemd unit/drop-in、LaunchAgent plist、Task Scheduler/服务配置、Docker Compose `.env` 或部署 env 文件中。当前运行时大多只读 `OPENBITFUN_*`,因此它们属于升级发现与配置迁移范围。 + +| 状态 | 旧变量 → 新变量 | 作用与优先级 | 迁移方式与注意事项 | +|---|---|---|---| +| 已核对 | `BITFUN_HOME` → `OPENBITFUN_HOME`;`BITFUN_USER_ROOT` → `OPENBITFUN_USER_ROOT`;`BITFUN_DEVICE_IDENTITY_PATH` → `OPENBITFUN_DEVICE_IDENTITY_PATH` | 决定产品 Home、用户数据根和设备身份文件的真实位置;P0 | migration bootstrap 必须在初始化新 `PathManager`、CLI config、Remote Connect store 前读取。新变量存在时始终优先;仅在目标变量缺失时把有效旧路径作为源定位/精确迁移候选,不能先使用新默认根再寻找旧数据 | +| 已核对 | `BITFUN_BUN_COMMAND`、`BITFUN_OPENCODE_BUN_HOST_ENTRY` → 对应 `OPENBITFUN_*` | Plugin Host/Server 的 Bun 命令和 host entry 覆盖;P1 | 仅在已识别的产品启动配置 owner 中改 key 并保留原值;目标变量优先。路径或命令需重新验证可执行性,不全局替换 shell/profile 文本 | +| 已核对 | `BITFUN_MINIAPP_MARKET_API_URL`、`BITFUN_APPEARANCE_MARKET_API_URL` → 对应 `OPENBITFUN_*` | 自定义 Market endpoint;P1 | 精确改名并验证 URL;目标值优先。不能把默认生产 URL 推断成用户显式配置,也不能因旧 endpoint 离线而删除它 | +| 已核对 | `BITFUN_CLI_DISABLE_AUTO_UPDATE`、`BITFUN_LOG_DIR` → 对应 `OPENBITFUN_*` | CLI 自动更新策略和自定义日志目录;P1/P2 | 在产品管理的 service/env 配置中迁移;布尔开关按存在语义保留,自定义路径保持原意。日志本身仍不迁移,且旧路径不能在未校验时被递归搬运 | +| 已核对 | `BITFUN_MIRROR` 及同组 mirror/Docker/release route 变量 → `OPENBITFUN_*` | Relay/self-host 的用户或 operator 部署选择;条件 P0/P1 | 若位于持久 `.env`、systemd/service 配置或产品管理的 deploy state,按 owner 的明确字段逐项迁移;自动探测结果和单次脚本变量不迁移。signing secret、release credential 等由 operator/secret manager 改名,Desktop 迁移器不得读取 | +| 已核对 | `BITFUN_MODELS_DEV_PATH/URL`、`BITFUN_OPEN_DEVTOOLS`、`BITFUN_BROWSER_VIDEO_DECODER_MODE`、`BITFUN_PERF_TRACE`、`BITFUN_WEBDRIVER_*` → 对应 `OPENBITFUN_*` | 开发、诊断、WebDriver 覆盖 | 默认不迁移;开发者若仍需要应在自己的启动配置中显式改名。迁移器不应把诊断行为带入普通生产首启 | +| 已核对 | `BITFUN_CLI_BACKGROUND_UPDATE` 以及 test child/PID/lock/ready/E2E 变量 → 对应 `OPENBITFUN_*` | 子进程协议、互斥或测试瞬时状态 | 不迁移。由新进程重新生成;把旧值带入新进程可能误判后台模式、锁或测试环境 | +| 已核对 | updater endpoint、release/signing key、Android signing、package/release channel 等 build/CI-only 变量 | 构建与发布基础设施 | 不属于用户数据迁移。由 CI/secret owner 独立改名和轮转,客户端不得发现、复制或记录这些值 | + +迁移器只能自动修改自己拥有且有结构定义的注册表值、service/drop-in、plist 或 `.env`。对任意 shell/profile/用户文件只报告“发现旧变量名及文件位置”,不得做全局文本替换;尤其不能在报告中记录变量值。 + +### 5.10 Hosted MiniApp/Skin Market 服务数据 + +这组是自部署/生产运维数据,不在 Desktop 用户根中。品牌提交把两个服务的系统路径、container/service 名和 artifact contract 一并改名;旧实例与新实例不能同时写同一数据库或 artifact tree。 + +| 状态 | 旧位置/对象 → 新位置/对象 | 作用与优先级 | 迁移方式与注意事项 | +|---|---|---|---| +| 已核对 | `/srv/bitfun-miniapp-market/data/market.sqlite` → `/srv/openbitfun-miniapp-market/data/market.sqlite` | MiniApp Market 账户、投稿、release 等权威数据库;P0 | 停止旧服务,用 SQLite backup 生成 offline copy,运行结构迁移并做 `integrity_check`,再切换新服务。不能复制 live DB 或让新服务先创建空库 | +| 已核对 | `/srv/bitfun-miniapp-market/artifacts` → `/srv/openbitfun-miniapp-market/artifacts` | 用户上传的 MiniApp package、截图等 artifact;P0 | 与 DB 引用闭包一起复制到 offline staging,校验引用、hash、size 和权限后原子切换;不能把 artifact 当构建缓存 | +| 已核对 | `/srv/bitfun-skin-market/data/market.sqlite` + `artifacts/` → `/srv/openbitfun-skin-market/...` | Skin 投稿/release DB、appearance package 和 preview;P0 | DB 与 artifact tree 必须成组离线迁移。只移动目录仍会留下旧 archive/schema/hash,需执行下述专用转换 | +| 已核对 | 两个旧根的 `backups/` → 新根 `backups/` | 数据库与 artifact 的 daily/weekly 恢复资产;P0/P1 | 保留原备份为不可变 legacy recovery set;不要让新 backup rotation 在验证前裁剪旧集合。进入新恢复集合前应演练完整 restore 和 schema migration | +| 已核对 | `/etc/bitfun-miniapp-market/market.env`、`/etc/bitfun-skin-market/market.env` → 对应 OpenBitFun 路径 | OAuth、session/CSRF secret 和服务配置;P0/高敏感 | 由 operator 在受控权限下复制并按字段改名,保持 `root:root`/`0600` 等权限;不得把内容写入迁移报告、普通 backup 或 Desktop 迁移器 | +| 已核对 | 旧 container、systemd service/timer、backup/restore executable 名 → OpenBitFun 对象 | 服务启动、备份调度和恢复入口;P0(系统集成) | 停旧 unit/container,安装新对象并指向已验证的新路径;验证 DB/artifact/backup 后再启用 timer。不得让新旧 timer 同时操作同一数据 | +| 已核对 | MiniApp DB JSON 中 `minBitfunVersion`/`minOpenbitfunVersion` → `minOpenBitFunVersion` | `submissions.metadata_json`、`releases.metadata_json` 的最低客户端版本 contract;P0 | 使用 `deploy/openbitfun-host/migrate-market-data-v1.py miniapp` 在 offline DB 上转换并规范为 `1.0.0`;脚本会拒绝传入声明为 live 的 DB,解析冲突时应中止而非猜测 | +| 已核对 | Skin `.bitfun-appearance`、`appearance.json` schema `bitfun.appearance` 和旧 DB 引用 → OpenBitFun contract | package 内容寻址、manifest、review bundle 与 DB 引用;P0 | 使用同一工具的 `skin` 模式重写 archive、改 schema、重算 package SHA-256/size/manifest/review-bundle hash 并同步 DB;旧 archive 移入 artifact tree 下 `migrated-bitfun-v1`。不能只改扩展名或只更新 DB | +| 已核对 | `/srv/bitfun-*-market/app` checkout | 部署源码,不是权威用户数据;clean checkout 不迁移/P2,dirty 修改可能是 operator 自定义且应作为 P1 档案 | 新路径按已记录 revision 重新部署;发现 dirty/untracked 文件时保全并报告,由 operator 审核后移植,不能用新 checkout 覆盖 | + +## 6. Windows 本机 SSH 数据 + +Desktop 和 Server 当前都以 `dirs::data_local_dir()/OpenBitFun/ssh` 初始化 `SSHConnectionManager`;品牌提交把中间目录从 `BitFun` 改为 `OpenBitFun`。 + +| 状态 | 旧目录内相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `ssh_connections.json` | 已保存连接;包括 host/port/user、认证类型、私钥/证书路径、ProxyJump、container、超时重试等,不含密码明文 | P0 | 结构化导入并合并;新增字段有 default。旧 connection ID 可能是 `ssh-@:`,当前稳定格式是无 port 的 `ssh-@` | +| 已核对 | `remote_workspace.json` | 用 `connectionId + remotePath` 标识的远程工作区恢复记录 | P0 | 旧版可能是单对象,当前只接受数组;转数组并与 connection ID 同步改名,保持 `remotePath` 的 POSIX 语义 | +| 已核对 | `known_hosts` | 产品内保存的 SSH host key/fingerprint 信任记录 | P0 | 完整迁移并按 `host:port` 合并;冲突必须显式报告,不能以新或旧值静默覆盖 | +| 已核对 | `.ssh_password_vault.key` + `ssh_password_vault.json` | 随机 AES-256-GCM key 和按 connection ID 索引的密码密文 | P0 | 两文件成组迁移。若 connection ID 改名,需要同步重键 vault entry;密文可原样保留,但必须验证 key 长度和至少一次受控解密 | +| 已核对 | 私钥/证书路径指向的外部文件 | 用户自己的 SSH key/certificate | 不属于品牌目录迁移 | 不复制外部 key;保留路径。若路径恰好位于旧 BitFun 目录内,作为异常情况提示用户或由专用规则迁移并更新引用 | + +connection ID 是四个 store 的关联键:`ssh_connections.json`、`remote_workspace.json`、密码 vault 和可能引用该连接的 workspace/dispatch 记录必须在同一个迁移事务中转换。任一凭据不可用时仍应保留 profile 和 workspace,明确提示重新输入凭据,不能把记录裁掉。 + +## 7. Tauri 与 WebView 持久化 + +WebView 数据不能整体视为运行缓存。品牌更名同时改变 Tauri identifier 和若干前端 storage key;因此即使复制旧 WebView profile,仍可能需要在前端存储层做旧 key → 新 key 的兼容读取或一次性改名。 + +| 状态 | 数据 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `%APPDATA%/com.bitfun.desktop/.window-state.json` | 窗口大小、位置、最大化等状态 | P2 | 可迁移;恢复前校验显示器和几何,避免旧屏幕布局把窗口放到不可见区域 | +| 已核对 | `webview-recovery.json` | WebView 异常恢复/重建标志 | 不迁移 | 旧版本的异常状态不应触发新版本恢复流程 | +| 已核对 | WebView Local Storage | 语言、布局宽度、模型选择、输入历史、待发送消息、dispatch 观察状态、终端 profile、更新跳过等 | P1,部分 P0 | 复制 profile 后还需迁移改名的 storage key;按 key 设定冲突策略 | +| 已核对 | IndexedDB `openbitfun-appearance` 及旧品牌对应库 | 主题/外观包 | P1 | 做数据库/对象级迁移或验证 UDF 复制可保留;用户自定义外观不可当缓存丢弃 | +| 已核对 | Network/Cookies、Local State、Profile Preferences | 嵌入页面或自动化相关登录态、浏览器 profile 密钥/偏好 | P1/可选 | 若迁移 Cookie,必须在 WebView 关闭时成组复制 `Local State`、Profile 和 Network/Cookies | +| 已核对 | `sessionStorage` | 当前 WebView 进程会话状态 | 不迁移 | 生命周期仅限当前进程 | +| 已核对 | Code Cache、Cache、GPUCache、shader/component caches、Crashpad、运行锁 | 缓存与运行时状态 | 不迁移 | 新 WebView 重建 | + +### 7.1 Local Storage 与 IndexedDB 键级清单 + +下表来自更名提交中 Web 存储 owner 的选择性比较,不包含 CSS class、DOM attribute、事件名等非持久化品牌字符串。所有 Local Storage 迁移都应在 Zustand store、输入队列和页面状态首次 hydrate 之前完成。 + +| 状态 | 旧 key/数据库 | 新 key/数据库 | 作用与优先级 | 转换与冲突策略 | +|---|---|---|---|---| +| 已核对 | `bitfun.workspace-session-view.v2` | `openbitfun.workspace-session-view.v2` | Session 列表分组、排序和过滤,P1 | 目标不存在时导入;若 payload 的 `filters.hiddenSources` 含旧枚举 `bitfun`,需改为 `openbitfun`,再走当前 store version migrate。目标已存在时保留目标偏好 | +| 已核对 | `bitfun-input-history` | `openbitfun-input-history` | 按 Session 保存的输入历史,P1 | 先走 store 内部 v1→v2 迁移,再按 Session 合并;保持“最近在前”、去重并执行每 Session 100 条上限。不能因 key 改名绕过现有 version migrate | +| 已核对 | `flowChat.pendingQueue.`;`flowChat.peerPendingQueue.v1.` | `openbitfun.flowChat.pendingQueue.v1.` | 尚未发送/等待 drain 的本地与 Peer composer queue,P0 | 这是本次提交中同时改了 key 格式的高风险项。旧 local key 补 `surfaceId=local`,旧 peer key保留元组;按 queue item ID/本地 Turn ID 合并且保留完整 composer payload。必须先写新 key 并验证,再删除新 profile 内的旧 key;源 profile仍只读 | +| 已核对 | `bitfun-dispatch-jobs-v1` | `openbitfun-dispatch-jobs-v1` | controller 的 dispatch observer Zustand 快照,P0/P1 | 与 `~/.bitfun/dispatch/outbound` 对账后导入;按 job ID 合并,backend/outbound 事实优先于陈旧 renderer projection,不得让旧快照复活已 terminal/已删除的 job | +| 已核对 | `bitfun-dispatch-dismissals-v1` | `openbitfun-dispatch-dismissals-v1` | 独立的 job/session dismissal ledger,防止陈旧 renderer 重新投影,P0 | 与目标 ledger 做集合并集并执行当前 2048 条边界;不能仅在 target key 不存在时复制,否则会丢 deletion authority | +| 已核对 | `bitfun-context-storage` | `openbitfun-context-storage` | composer 选择的非 image、非 PR 上下文,P1 | 校验 Zustand envelope;目标为空时导入。两侧均有数据时按 context ID 合并、目标顺序优先,并继续排除当前 owner 明确不持久化的 image/PR context | +| 已核对 | `bitfun-i18n-state` | `openbitfun-i18n-state` | 当前语言、fallback 和自动检测偏好,P1 | 只迁当前 locale contract 仍接受的字段和值;目标已设置时保留目标。`bitfun-language` 不是当前活动 owner | +| 已核对 | `bitfun-manual-terminal-profiles:` | `openbitfun-manual-terminal-profiles:` | 每工作区的手工终端 profile,P0/P1 | 从已知 workspace registry 枚举 key,不遍历猜测工作区;按 profile ID 合并并保持命令、cwd/env 等敏感字段,不输出内容。工作区路径若搬迁需同步重键 | +| 已核对 | `bitfun:review-platform:last-remote:` | `openbitfun:review-platform:last-remote:` | 每工作区最近选择的 Review remote,P1 | 目标缺失时复制;校验 remote ID 仍存在。工作区路径变更时同步重键,不用控制端 path 语义改写远程路径 | +| 已核对 | `bitfun:settings:acp-agents:hidden-remote-connections:v1` | `openbitfun:settings:acp-agents:hidden-remote-connections:v1` | 用户隐藏的远程 ACP connection ID 集合,P1 | 与目标集合并集;若第 6 节迁移 connection ID,必须同步改值。无效 ID 保留在报告中,不静默当作用户取消隐藏 | +| 已核对 | `bitfun.sessionUsage.export.redactPaths` | `openbitfun.sessionUsage.export.redactPaths` | Session Usage 导出时是否隐藏路径,P1(隐私偏好) | 只接受布尔语义;目标已存在时保留目标。迁移失败使用当前更保守的默认值,不把失败解释为用户明确关闭脱敏 | +| 已核对 | `bitfun.reasoning-presets.recent.v1` | `openbitfun.reasoning-presets.recent.v1` | 每模型最近选择的 reasoning preset,P2 | 按 model ID 合并,目标值优先;仅保留当前模型声明仍支持的 preset,未知值可留在 legacy report | +| 已核对 | `bitfun:remote-connect:disclaimer-agreed:v1` | `openbitfun:remote-connect:disclaimer-agreed:v1` | 远控免责声明已确认状态,P2 | 仅将有效 `true` 导入到空目标;它不是账户授权或设备身份,迁移失败不得影响 Remote Connect 数据本身 | +| 已核对 | `bitfun:update:lastDailyPromptDate`、`bitfun:update:lastPromptedLatestVersion`、`bitfun:update:skippedVersion` | 对应 `openbitfun:update:*` | 更新提示/跳过版本偏好,P2 | 三项作为一个小 store 迁移;校验日期/版本字符串,目标值优先,失败不阻塞主迁移 | +| 已核对 | `bitfun:leftPanel*`、`bitfun:rightPanel*`、`bitfun:bottomTerminalPanelLastHeight` | 对应 `openbitfun:*` | 布局尺寸/折叠偏好,P2 | 当前实际读取的是 `rightPanelLastWidth` 和 `bottomTerminalPanelLastHeight`;只迁活动项并校验有限正数。其余旧键不应因常量仍存在就自动导入 | +| 已核对 | `bitfun-appearance` IndexedDB,stores `packages`、`catalog` | `openbitfun-appearance` | 用户导入/编辑的 Appearance package 与 catalog,P1;用户创作项可视为 P0 | 不能只创建同名空库。按 package ID 读取、校验并写入目标 DB;同 ID 目标项优先,保留冲突副本/报告。需要保证 catalog 与 package 引用闭包 | +| 已核对 | `bitfun-market-theme`、`bitfun-market-locale` | `openbitfun-market-theme`、`openbitfun-market-locale` | MiniApp Market 页面主题和语言;P2 | 同 origin 页面先读新 key,目标缺失时校验旧值并写新 key。普通外部浏览器由页面自己迁移,Desktop 迁移器不扫描浏览器 profile | +| 已核对 | `bitfun-skin-market-theme`、`bitfun-skin-market-locale` | `openbitfun-skin-market-theme`、`openbitfun-skin-market-locale` | Skin Market 页面主题和语言;P2 | 与 MiniApp Market 相同;若站点换了 origin,Local Storage 不会自动跨域,需要服务端 bridge 或用户可控 export/import | +| 已核对 | `bitfun.design-lab.locale` | `openbitfun.design-lab.locale` | Design Lab 语言;P2(开发者) | 仅在 Design Lab 自己的 origin/profile 中迁移有效 locale;不导入 Desktop 主应用 profile | +| 已核对 | `bitfun.design-lab.token-drafts.v1` | `openbitfun.design-lab.token-drafts.v1` | Design Token 编辑草稿;开发者场景 P0/P1 | 在 token editor 首次 hydrate 前按草稿 identity 合并,目标草稿优先并保留冲突副本;不能因它属于开发工具而当缓存丢弃 | +| 已核对 | `bitfun-mobile-language`、`bitfun-mobile-theme` | `openbitfun-mobile-language`、`openbitfun-mobile-theme` | Mobile Web 语言和主题;P1/P2 | 在 Mobile Web bootstrap/Provider 读取前迁移当前 locale/theme contract 接受的值;目标值优先 | +| 已核对 | `bitfun.mobile.install_id`、`bitfun.mobile.user_id` | `openbitfun.mobile.install_id`、`openbitfun.mobile.user_id` | Mobile Web 稳定 install identity、已确认 user ID/表单预填和无账户模式自动重连;P0 | 两项按一个 identity 组合迁移,保持 install ID 原值;目标已有不同 install ID 时进入设备冲突流程,不能覆盖或生成新 ID 后假装完成迁移。user ID 属敏感标识,报告不记录值 | +| 已核对 | `bitfun.mobile.user_id_failure_count`、`bitfun.mobile.user_id_lock_until` | 对应 `openbitfun.mobile.*` | Mobile Web 本地配对保护失败计数与 60 秒 lockout;P0(仍生效的安全状态) | 成组读取并校验数值;仅保留尚未过期的 lockout,过期后由 owner 清除两项。不能只迁 failure count 而丢 lock deadline,也不能把迁移失败解释为成功验证 | +| 已核对 | `bitfun.mobile.last_selected_model_id` | `openbitfun.mobile.last_selected_model_id` | Mobile Web 最近模型选择;P1 | 目标缺失时导入;加载 catalog 后通过现有 normalize/resolve 逻辑验证,失效模型降级为 `auto`,但在 legacy report 中保留旧值摘要而不重写为另一个模型 | +| 已核对 | IndexedDB `bitfun-mobile-remote-cache`,stores `session-state`、`transcripts` | `openbitfun-mobile-remote-cache` | 按 account/device 保存的远端 Session/工作区列表与 transcript 离线投影;P1 | 权威数据仍在目标 Desktop/Relay,但缓存含离线可见历史。按 `accountId + deviceId` scope、Session ID 合并,保留较新的记录并遵守当前每设备数量上限;不能复制底层 IndexedDB 文件到已有目标库 | +| 已核对 | `bitfun-flow-chat-state`、`bitfun-flow-chat-global`、`bitfun-session-ids`、`bitfun-session-*` | 提交中改成 `openbitfun-*`,但当前 constructor 仍会主动清除此组 | 已退休的旧 renderer Session cache,不迁移 | 权威会话在 Rust Session store。不要把这组键导入新 profile;否则下一次启动也会被 `clearOldStorage()` 清掉 | +| 已核对 | `bitfun-*` 常量组中的 `language`、左右 panel、recent workspaces、user preferences、model configs、chat history、diff warning | 提交中有对应 `openbitfun-*` 常量 | 当前 checkout 除 `manual-terminal-profiles` 外没有活动 reader,默认不迁移 | 这些仅定义于 `shared/constants/app.ts`,不能据名称把过时 browser cache 当权威数据。若未来 owner 恢复,需单独补 schema owner 和迁移测试 | +| 已核对 | `bitfun.deepReview.skipCostConfirmation` | `openbitfun.deepReview.skipCostConfirmation` | 当前只在测试 fixture 中出现,生产代码无 reader | 不迁移。不能根据测试字符串推断存在活动偏好 store | +| 已核对 | `bitfun.flowChat.mockBackgroundCommands` | `openbitfun.flowChat.mockBackgroundCommands` | 仅开发环境 mock 开关 | 不迁移 | +| 已核对 | `wing_coder_current_model_config`、`editor-config` | 名称未变 | 当前模型选择与编辑器偏好,P1/P2 | 若采用选择性导出/导入而非复制完整 Local Storage,仍应分别保留;前者的 ID 必须在已迁移模型配置中存在,后者只合并当前接受字段 | + +`sessionStorage` 中改名的 `bitfun:flowchat:*`、startup reload、unexpected-exit/config-recovery notice、thread-goal dismiss、`pendingProjectDescription` 和 `pendingOpenSettings` 都不迁移。它们要么只用于当前 renderer 生命周期,要么是一次性导航/防重复提示状态;跨版本恢复反而可能跳过新进程应执行的动作。 + +### 7.2 WebView profile 的迁移边界 + +- **目标 UDF 尚不存在**:在 Desktop/WebView 完全退出后,将旧 UDF 中的持久子树复制到同卷 staging,再排除缓存、Crashpad、运行锁和 session-only 数据;校验后原子成为新 UDF。随后在任何页面 store hydrate 前执行 7.1 的 key/schema 迁移。 +- **目标 UDF 已存在**:不能把旧 `Local Storage/leveldb`、IndexedDB 或 Cookies 的底层文件覆盖/拼接到目标目录。应以独立 WebView2 environment 或受支持的 store 导出器只读打开旧 UDF,再通过各 owner 合并到目标;若无法安全读取,保留旧 UDF并报告,不创建“成功但数据为空”的标记。 +- **Cookie/登录态是单独承诺**:只有产品明确承诺保留嵌入页登录态时,才迁移 `Local State`、对应 Profile、Network/Cookies 和相关密钥材料;同一 Windows 用户之外不可假设密文可解。浏览器自动化 profile 仍按 4.4 独立处理,不能和 Desktop WebView UDF混用。 + +### 7.3 Desktop 开发 identifier + +`src/apps/desktop/tauri.dev.conf.json` 也从 `com.bitfun.desktop.dev` 改为 `com.openbitfun.desktop.dev`。它会形成与正式版、以及新 dev build 都不同的 Tauri app-data/WebView profile: + +| 状态 | 旧位置 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | `%APPDATA%/com.bitfun.desktop.dev` | dev build 的窗口状态和 Tauri app data | P2(开发者) | 只迁有价值的窗口偏好;排除 `webview-recovery.json`。不得导入正式 `com.openbitfun.desktop` profile | +| 已核对 | `%LOCALAPPDATA%/com.bitfun.desktop.dev/EBWebView` | dev build 的 Local Storage、IndexedDB、Cookie 与缓存 | 分项判断(开发者) | 按 7.1/7.2 同样做 store 级迁移;通常不承诺保留 dev 登录态,不能把 dev Cookie 合并进正式 profile | + +生产升级器默认只处理正式 identifier。dev profile 应由显式开发者迁移开关处理,以免普通用户机器上的历史测试 profile 污染生产数据。 + +### 7.4 Cookie 名称变更 + +Cookie 是否迁移取决于服务端 session 的生命周期,不能看到 `Cookies` 数据库里有旧名称就统一改名: + +| 状态 | 旧名 → 新名 | 生命周期/作用 | 迁移结论 | +|---|---|---|---| +| 已核对 | `bitfun_html_preview` → `openbitfun_html_preview` | Desktop 每次 `html_preview_create` 生成随机 token、随机 localhost 端口和进程内 preview server;Cookie 无 `Max-Age`/`Expires`,release 或进程退出后 server 状态消失 | 不迁移。旧 token 对新进程没有授权意义;即使 UDF 中残留也应排除/忽略,不得复制为新名 | +| 已核对 | `bitfun_market_session` + `bitfun_market_csrf` → `openbitfun_market_*`;`bitfun_skin_session` + `bitfun_skin_csrf` → `openbitfun_skin_*` | MiniApp/Skin Market 的 7 天 Web session;session token 在服务端 DB 中校验,CSRF token 必须与其 hash 配对,Cookie 分别 scoped 到 `/miniapp`、`/skin` | P1(承诺保留市场登录态时)。优先让服务端在有限兼容期接受完整旧 session+CSRF 对,验证仍有效后重新签发/设置新名称并 expire 旧名称;不要直接编辑 Chromium Cookies DB,也不能只改 session cookie 而漏 CSRF。若不承诺登录态则明确要求重新登录 | +| 已核对 | `bitfun_page_access` → `openbitfun_page_access` | Relay Pages 的 8 小时浏览器 grant;token 只映射到 Relay 进程内 `PageAccessManager`,服务重启即失效 | 不迁移。升级重启后旧 grant 本来不可恢复;用户重新完成 Page 登录/授权即可。不能把它与 Relay DB 或账户 session 混为一谈 | + +市场 Cookie 可能存在于 Desktop WebView,也可能存在于普通外部浏览器;迁移系统只能处理产品管理的 UDF。外部浏览器应依赖服务端旧名兼容和安全重签,不能扫描或修改用户浏览器 profile。 + +### 7.5 Installer WebView identifier + +安装器也从 `com.bitfun.installer` 改为 `com.openbitfun.installer`,会形成新的 Tauri app-data/WebView profile。对更名前后安装器源码的定向扫描没有发现 Local Storage、sessionStorage 或 IndexedDB owner;安装步骤状态保存在 React 进程内,最终通过 command 写入产品配置和 installer state。 + +| 状态 | 旧位置 | 作用 | 优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | Windows `%APPDATA%/com.bitfun.installer`、`%LOCALAPPDATA%/com.bitfun.installer/EBWebView` | Installer 自己的 Tauri/WebView profile;未发现应用级持久 store | 不迁移 | 默认视为安装器运行缓存/profile,由新安装器重建;不要把它并入 Desktop 的 `com.openbitfun.desktop` UDF | +| 已核对 | Installer command 写入的 `%APPDATA%/bitfun/config/installer-state.json`、语言/主题/模型等产品配置 | 安装结果和用户选择,owner 在产品用户目录而非 Installer WebView | 按对应产品 store 分项迁移 | `installer-state.json` 按 4.1/第 8 节处理;配置字段随 `app.json`/CLI config 的 schema-aware 迁移,不能因为 Installer profile 不迁而漏掉这些输出 | + +## 8. Windows 安装目录、注册表与快捷方式 + +这一节属于**升级系统集成**而非用户数据复制,但品牌更名后若不处理,会得到两个并存安装、两个卸载项或失效快捷方式。当前新安装器只读取 OpenBitFun 名称的注册表键,并且总是把用户选择的父目录规范化到 `OpenBitFun` 子目录;它不会识别或原地接管旧 `BitFun` 安装。 + +| 状态 | 旧对象 | 新对象 | 作用/优先级 | 迁移方式与注意事项 | +|---|---|---|---|---| +| 已核对 | 默认 `%LOCALAPPDATA%/BitFun`;自定义父目录下的 `BitFun/` | 对应 `OpenBitFun/` | 已安装二进制与资源;P0 升级流程、非用户数据 | 不把旧安装目录当用户数据搬迁。先从旧注册表确认真实位置和签名/版本,正常安装并验证新 payload,再调用受信任的旧卸载路径或按 manifest 清理旧 payload。发现未知文件时保全并报告,不能递归删除整个旧目录 | +| 已核对 | `bitfun-desktop.exe` | `openbitfun-desktop.exe` | 主程序入口;P0 | 所有快捷方式、PATH、Run 值和卸载记录必须成组更新;不能只改文件名,因为签名、资源和 sidecar 也随发行包变化 | +| 已核对 | `HKCU/HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\BitFun` | `...\Uninstall\OpenBitFun` | Add/Remove Programs、安装位置、旧卸载命令;P0 | 旧键只用于发现和调用旧卸载器,不能复制后字符串替换其中命令。新安装成功后写新键,再移除确认属于旧产品的键;兼顾 per-user/per-machine hive | +| 已核对 | `HKCU/HKLM\Software\BitFun Team\BitFun` | `...\OpenBitFun Team\OpenBitFun` | Tauri NSIS `MANUPRODUCTKEY` 安装位置;P0 | 读取旧默认值作为发现证据;不要把旧 `BitFun` 末段直接交给当前 `prepare_install_target`,否则会形成 `BitFun\OpenBitFun` 嵌套目录 | +| 已核对 | Desktop/Start Menu 的 `BitFun.lnk`、可能的 `Programs\BitFun\` | `OpenBitFun.lnk`、当前 Start Menu 布局 | 用户入口;P1 | 新快捷方式验证目标存在后再删旧快捷方式;不能原样复制 `.lnk`,其中目标仍指向旧 exe | +| 已核对 | `HKCU\...\Run` 的 `BitFun` 值、用户 PATH 中的旧安装目录 | `OpenBitFun` 值、新安装目录 | 自动启动和 CLI/程序发现;P0/P1 | 若用户原先启用则等价迁移,避免双启动;PATH 精确按规范化目录元素替换,不做子串替换。目标验证前保留旧入口 | +| 已核对 | `HKCU\Software\Classes\Directory\{Background\}shell\BitFun` | 当前产品不再注册该菜单 | 退休的资源管理器右键菜单;不迁移 | 新版验证成功后清理确认为旧产品的键;当前卸载代码只清理 `OpenBitFun` 名称,升级路径还需显式覆盖旧键 | +| 已核对 | `%APPDATA%/bitfun/config/installer-state.json` | `%APPDATA%/openbitfun/config/installer-state.json` | 最近安装路径;P2 | 与 4.1 同一 store。只能作为候选提示,必须再与旧注册表和实际签名 payload 交叉验证;它不能证明安装仍有效 | + +安装器在安装末期还会写新根的首次启动语言、主题和模型配置。因此品牌迁移必须先运行,或让安装器通过同一个 schema-aware merge owner 写入;否则安装器会先制造“目标非空”,使后续迁移误判为一次普通全新安装。 + +## 9. Android、iOS 与 HarmonyOS 应用数据 + +这一组不在 Desktop 的两个根目录内,但同一个更名提交同时修改了移动端 package/bundle ID 和 store 名。若这些客户端已向用户发布,它们属于独立的 P0 升级迁移项目。 + +### 9.1 跨沙箱边界 + +| 平台 | 旧应用标识 | 新应用标识 | 直接影响 | 迁移边界 | +|---|---|---|---|---| +| Android | `com.bitfun.mobile`(debug 为 `.debug`) | `com.openbitfun.mobile`(debug 为 `.debug`) | 产生新的 `/data/user//` 私有目录、UID 和 AndroidKeyStore 访问域;不是同一 package 的覆盖升级 | 首选保持已发布 application ID 不变,只改代码 namespace/品牌。若新 ID 已不可逆,需先发布由旧签名应用执行的显式 exporter/受签名权限保护的桥接,再由新应用导入;新应用不能直接复制旧 SharedPreferences/DB/Keystore | +| iOS | `com.bitfun.mobile.ios` | `com.openbitfun.mobile.ios` | 新 data container、UserDefaults domain;默认 Keychain access group 也会随签名 app identifier 改变 | 首选保持已发布 bundle ID。否则需在旧版仍可更新时建立 App Group/Keychain access group 或加密导出协议,并验证 provisioning entitlement;仅保持相同 Team ID 不足以证明新版可读旧 Keychain | +| HarmonyOS | `com.bitfun.app` | `com.openbitfun.app` | 新应用沙箱、Preferences/RDB/HUKS 域;distributed KV 的隔离还取决于 bundleName + storeId | 首选保持已发布 bundleName。否则由旧应用先解密并导出,再通过平台支持的受控通道导入;要同时覆盖手机和手表,不能只迁一端的 distributed KV | + +提交后的当前 runtime owner 未保留下表旧名称的兼容读取;而 package/bundle ID 已经先把旧数据隔离在另一个应用域。因此“新应用启动时检查旧文件名”本身并不能完成移动端迁移。 + +### 9.2 Android/iOS 共享数据域 + +| 状态 | 旧 store | 新 store | 作用/优先级 | 转换与注意事项 | +|---|---|---|---|---| +| 已核对 | `bitfun-mobile.db` | `openbitfun-mobile.db` | SQLDelight:本地 General Chat Session/message、composer draft,以及按设备保存的远端 Session 列表、transcript 和 replay cursor;用户聊天/草稿 P0,远端投影 P1 | 由旧应用导出逻辑在数据库一致性点读取,再让新 schema owner transaction 导入全部关联表;不能复制正在打开的 SQLite 文件,也不能只迁 Session 不迁 message/draft/cursor | +| 已核对 | Android `bitfun_install/install_id`;iOS UserDefaults `bitfun.mobile.install_id` | 对应 `openbitfun_*` / `openbitfun.mobile.*` | 移动客户端向 Desktop/Relay 呈现的稳定设备身份;P0 | 保持原值,避免升级被当作全新设备并留下孤立路由。目标已有不同 ID 时不能覆盖,应进入账户/设备冲突流程 | +| 已核对 | Android `bitfun_app_settings`;iOS `bitfun.mobile.language` | 对应 OpenBitFun 名称 | 主题(Android)与语言偏好;P1/P2 | 只导入当前枚举/locale contract 接受的值;目标已由用户设置时保留目标 | +| 已核对 | Android `bitfun_secure_cloud_account` + AndroidKeyStore alias `bitfun.mobile.cloud_account`;iOS Keychain service `com.bitfun.mobile.account` | 对应 OpenBitFun secure domain | `cloud_account_session`:relay URL、username、token、user ID、account master key、目标设备选择;P0/高敏感 | 必须由旧安全域解密读取、校验记录后重新加密写入新域;不能复制 Android preferences 密文或只改 alias/service。全部写入成功后才建立已登录状态 | +| 已核对 | Android `bitfun_secure_general-chat` + alias `bitfun.mobile.general-chat`;iOS service `com.bitfun.mobile.generalchat` | 对应 OpenBitFun secure domain | General Chat base URL、model、API key、selected model ID;P0/P1/高敏感 | 四个逻辑字段成组迁移,API key 不得出现在迁移日志;目标存在时按完整 provider 配置裁决,不能混出“旧 URL + 新 key” | +| 已核对 | Android `bitfun_secure_pairing_protection` + alias `bitfun.mobile.pairing_protection`;iOS service `com.bitfun.mobile.pairing` | 对应 OpenBitFun secure domain | `user_id_protection` 连续失败次数和 lockout 截止时间;P0(安全状态) | 保留仍未过期的冷却状态;过期记录由 owner 清除。迁移失败不能被解释为一次成功配对,也不能用它替代 install identity | + +### 9.3 HarmonyOS store 级清单 + +| 状态 | 旧 store/alias | 新 store/alias | 作用/优先级 | 转换与注意事项 | +|---|---|---|---|---| +| 已核对 | Preferences `bitfun_remote_identity` | `openbitfun_remote_identity` | install/device identity;P0 | 保持有效身份;目标冲突不覆盖 | +| 已核对 | Preferences `bitfun_cloud_account` + HUKS alias `bitfun_cloud_account_session` | 对应 `openbitfun_*` | 云账户 session 密文与加密 key;P0/高敏感 | 在旧应用域中解密并验证,随后用新 HUKS key 重新加密;复制 preferences 密文后改 alias 无法解密 | +| 已核对 | RDB `bitfun_general_chat.db` | `openbitfun_general_chat.db` | General Chat 本地 Session、消息和草稿;P0 | 按 RDB schema/transaction 导出导入,保留 Session-message-draft 引用闭包 | +| 已核对 | Preferences `bitfun_general_chat_config` + HUKS alias `bitfun_general_chat_api_key` | 对应 `openbitfun_*` | General Chat endpoint/model/选择和 API key;P0/P1/高敏感 | 配置与 secret 成组迁移并重新加密;不能分别采用不同侧的 provider 字段 | +| 已核对 | RDB `bitfun_remote_sessions.db`、`bitfun_remote_chat.db` | 对应 `openbitfun_*` | 远端 Session 列表、消息、cursor/离线投影;P1 | 按 device/session 主键合并,目标事实优先;解析失败保留旧记录而不是重置空库 | +| 已核对 | Preferences `bitfun_app_locale` | `openbitfun_app_locale` | locale;P1/P2 | 校验当前 locale contract 后导入 | +| 已核对 | distributed KV `bitfun_harmony_handoff_s1` | `openbitfun_harmony_handoff_s1` | 手机与手表间的账户/控制 handoff;P0(活动交接) | store ID 和 bundleName 都参与隔离。旧端、新端及手表必须按同一版本化协议双读/转存;只改 store ID 会让两端看到空 store | +| 已核对 | KV key `bitfun.account.provision.request`、`bitfun.account.provision.response` | `openbitfun.account.provision.request`、`openbitfun.account.provision.response` | phone/watch provisioning 的活动 request/response;P0(活动交接) | 与 handoff store ID 和 bundleName 作为一个协议版本迁移。兼容窗口内两端双读旧/新 key,并按 request identity 配对;不能只复制 response、只改 store ID,或让旧 response 应答不相关的新 request | + +移动端凭据导出必须是用户可感知、短时、一次性的加密迁移流程,并且在新应用确认导入前不删除旧应用数据。若旧包已无法更新、平台又没有共享容器/备份恢复能力,应明确报告“无法自动迁移并需重新登录”,不能伪造迁移成功。 + +## 10. 迁移系统必须遵守的合并与失败策略 + +1. **旧源只读且不删除。** 首次成功也不要立即删除旧目录;保留可回退窗口,由单独的显式清理流程处理。 +2. **在各 store 首次打开前执行。** SQLite、密钥 vault、WebView UDF 等不能在新旧进程仍持有写锁时复制。 +3. **新目录为空时原子提交。** 先写同卷 staging,校验成功后 rename/replace;失败不留下“看似已迁移”的半成品。 +4. **新目录已有数据时按记录合并。** 不允许整目录覆盖。默认保留新数据;ID 冲突应保留双方或由 owning store 明确裁决,并写入可见报告。 +5. **凭据必须先读旧、再写新、再提交 metadata。** 解密失败不能静默等价为“用户未登录”,也不能先删除旧 secret。 +6. **SQLite 使用 backup/transaction。** 不单独复制 `.sqlite` 主文件;WAL 中可能还有未 checkpoint 的提交。 +7. **迁移可重入。** 维护 migration manifest,记录源/目标、store 版本、hash 或稳定摘要、每项状态和错误;重复启动只重试未完成/可重试项。 +8. **解析失败保留并显式降级。** 不能通过删除、重置或静默创建空配置来“恢复”。 +9. **远程路径保持 POSIX 语义。** `remote_ssh` 和远端 dispatch/workspace 记录不能用控制端 Windows path join/split 重新解释。 +10. **敏感内容不进入日志。** 报告文件只记录 store、路径、错误类别和摘要,不输出 Token、Cookie、密钥、配置值、会话文本或终端内容。 + +## 11. 静态核对结论与实现阶段待办 + +本清单已完成对已知产品根、workspace runtime、Session/Dispatch、Remote Connect、SSH、WebView/browser storage、CLI updater、Hosted Market、安装系统、Android/iOS/HarmonyOS,以及品牌提交中相关 storage key/service name/路径名的定向静态核对。没有展开 4,748 文件的完整 diff;结论来自当前 owner 与 `8784bbc^` 对应文件的选择性比较。 + +“静态核对完成”不等于迁移系统已经可发布。实现阶段仍需: + +1. 建立最早期 migration bootstrap,在 `PathManager`、CLI config、SQLite、vault、WebView 和各页面 store 首次打开前完成源发现;旧根覆盖环境变量必须在这里处理。 +2. 建立显式 store registry。每项声明 source、target、owner、schema/version、敏感级别、冲突策略、可重入 marker 和验证器;不提供“递归复制整个旧根”的后门。 +3. 先实现 P0 owner:全局配置、workspace registry、Session/runtime-events、设备/账户身份、权限/cron、Remote Connect、SSH、移动端身份/凭据、活动 Dispatch/worktree、Market DB/artifact。 +4. 为 URI、metadata、wire key、pairing link 和混合版本 peer 增加兼容期双读/能力协商;持久历史保留原始值,新写入只用 OpenBitFun canonical 名称。 +5. 对旧 payload 做 fixture 驱动的迁移测试:legacy deserialize、目标为空、目标已有数据、冲突、损坏/WAL、重复执行、失败回滚、old-payload round trip 和敏感日志无泄漏。仅用当前版本新写数据的测试不算升级覆盖。 +6. 提供默认 dry-run 和机器可读/用户可读报告,只输出路径、store、数量、摘要和错误类别;实际迁移继续保持旧源只读。清理旧数据必须是迁移验证后的独立显式动作。 +7. 分别验证 Windows/macOS/Linux,本地 workspace、Remote workspace、Remote control、Peer Device、Detached Dispatch,以及 Hosted Market offline cutover。当前文档没有任何运行证据,不能用本地静态检查替代这些场景。 + +对于 `data/telemetry/uid`、旧 workspace `memory/` 和任意机器上出现的未知非空目录,当前结论仍是“保全并报告,不导入 active store”。这不是遗漏项,而是没有可靠 owner/schema 时的明确安全边界。 + +## 12. 主要证据文件索引 + +| 数据域 | 主要当前 owner/证据 | +|---|---| +| 产品路径与 identity | `src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs`;`src/crates/contracts/core-types/src/product_identity.rs`;`src/apps/cli/src/config.rs` | +| CLI updater 状态 | `src/apps/cli/src/self_update.rs` | +| Session/workspace runtime | `src/crates/assembly/core/src/agentic/persistence/manager.rs`;`src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs` | +| tool path URI | `src/crates/execution/tool-contracts/src/framework.rs`;`src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs` | +| Remote Connect identity/session/sync | `src/crates/services/services-integrations/src/remote_connect/device.rs`;`session_store.rs`;`sync_state.rs`;`src/crates/services/services-integrations/src/remote_connect.rs` | +| MiniApp Market 客户端凭据 | `src/crates/services/services-integrations/src/miniapp_market/credentials.rs` | +| Web UI storage | `src/web-ui/src/shared/constants/app.ts` 及第 7.1 节各 key 的直接 reader;`src/web-ui/src/infrastructure/appearance/storage/AppearanceStorage.ts` | +| Mobile Web storage | `src/mobile-web/src/pages/PairingPage.tsx`;`src/mobile-web/src/pages/ChatPage.tsx`;`src/mobile-web/src/services/RemoteCache.ts`;theme/i18n providers | +| Hosted Market | `deploy/openbitfun-host/migrate-market-data-v1.py`;`deploy/miniapp-market/`;`deploy/skin-market/` | +| Installer identifier | `OpenBitFun-Installer/src-tauri/tauri.conf.json`;更名前路径 `BitFun-Installer/src-tauri/tauri.conf.json` | +| HarmonyOS handoff/provision | `src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchHandoffStore.ets`;`WatchProvisionProtocol.ets` | +| 品牌前后对照 | 当前 checkout 与提交父版本 `8784bbc^` 的上述对应文件;未使用完整 diff 作为结论来源 | diff --git a/src/crates/services/legacy-migration/tests/fixtures/support-matrix.json b/src/crates/services/legacy-migration/tests/fixtures/support-matrix.json new file mode 100644 index 0000000000..d6aa4affc4 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/support-matrix.json @@ -0,0 +1,78 @@ +{ + "matrixVersion": 1, + "sourceProduct": "bitfun", + "supportedVersions": ">=0.2.0,<1.0.0", + "canonicalFixtureVersion": "0.2.19", + "domains": [ + { + "id": "settings_and_credentials", + "owner": "configuration and credential store owners", + "atomicSubdomains": ["settings", "credentials"], + "requiredInputs": ["user-root/config/app.json"], + "conflictPolicy": "target_explicit_value_wins" + }, + { + "id": "agents_skills_and_miniapps", + "owner": "Agent, Skill, and MiniApp product-domain owners", + "atomicSubdomains": ["skills", "miniapps", "agents"], + "requiredInputs": [ + "user-root/agents", + "user-root/skills", + "user-root/data/miniapps" + ], + "conflictPolicy": "preserve_both_or_report" + }, + { + "id": "workspaces_sessions_and_tasks", + "owner": "workspace, Session persistence, and Agent coordination owners", + "atomicSubdomains": ["workspace_sessions", "agent_coordination"], + "requiredInputs": [ + "user-root/data/workspace_data.json", + "home/projects", + "user-root/data/agent-runtime/coordination.sqlite" + ], + "requiredReferences": [ + "session.id -> coordination_sessions.parent_session_id", + "agents.child_session_id -> child session.id", + "background_tasks.parent_dialog_turn_id -> parent turn.id", + "background_tasks.child_dialog_turn_id -> child turn.id" + ], + "conflictPolicy": "group_blocked_when_reference_closure_fails" + }, + { + "id": "memory", + "owner": "Memory store owner", + "atomicSubdomains": ["structured_memory", "file_memory"], + "requiredInputs": [ + "user-root/data/memories/memories.sqlite", + "home/memories" + ], + "conflictPolicy": "stable_id_then_content_hash" + }, + { + "id": "remote_connections_and_devices", + "owner": "Remote Connect and Remote SSH owners", + "atomicSubdomains": ["remote_connect_devices", "remote_ssh"], + "requiredInputs": [ + "home/device_identity.json", + "home/remote_connect_persistence.json", + "ssh/ssh_connections.json", + "ssh/remote_workspace.json", + "ssh/known_hosts" + ], + "conflictPolicy": "target_identity_wins_and_reauthenticate_when_unportable" + } + ], + "globalExcludes": [ + "cache", + "logs", + "temp", + "skills/.system", + "data/agent-runtime/ownership", + "data/agent-runtime/ipc-v*", + "**/*.lock", + "**/*.pid", + "**/*-shm", + "**/request-traces" + ] +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md new file mode 100644 index 0000000000..18d9d6d0eb --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md @@ -0,0 +1,17 @@ +# BitFun 0.2.19 migration fixture + +This fixture is a synthetic, de-identified representation of the final BitFun +storage generation before commit `8784bbc4258131b4e74e6f9192a23b43c454fa94`. +It contains one relationship-complete record for each V1 migration domain and +explicitly includes files that must be excluded. + +The fixture is source data, not a ready-to-copy OpenBitFun profile. Tests build +SQLite databases from the checked-in SQL, enable WAL where needed, and validate +the fixture through the migration readers before executing a plan. + +Supported source range for V1 is `>=0.2.0,<1.0.0`. The fixture's canonical +source revision is `845b4b4d2925f7c41e7e03a4a618606fbd0da8b6`. + +All identities, paths, message text, tokens, host keys, and credentials are +synthetic. Secret-bearing owners are represented only by metadata that forces +the migrator to report reauthentication; no usable secret is stored here. diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/fixture-manifest.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/fixture-manifest.json new file mode 100644 index 0000000000..ccb438fb64 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/fixture-manifest.json @@ -0,0 +1,38 @@ +{ + "fixtureVersion": 1, + "sourceProduct": "bitfun", + "sourceVersion": "0.2.19", + "sourceRevision": "845b4b4d2925f7c41e7e03a4a618606fbd0da8b6", + "domains": [ + "settings_and_credentials", + "agents_skills_and_miniapps", + "workspaces_sessions_and_tasks", + "memory", + "remote_connections_and_devices" + ], + "expectedIncluded": [ + "user-root/config/app.json", + "user-root/agents/researcher.md", + "user-root/skills/user-skill/SKILL.md", + "user-root/data/miniapps/custom-notes/meta.json", + "user-root/data/workspace_data.json", + "user-root/data/agent-runtime/coordination.sql", + "user-root/data/memories/memories.sql", + "home/projects/workspace-demo/sessions/session-1/metadata.json", + "home/runtime-events/session-1.jsonl", + "home/memories/MEMORY.md", + "home/device_identity.json", + "home/remote_connect_persistence.json", + "ssh/ssh_connections.json", + "ssh/remote_workspace.json", + "ssh/known_hosts" + ], + "expectedExcluded": [ + "user-root/skills/.system/builtin/SKILL.md", + "user-root/data/miniapps/builtin-gomoku/index.html", + "user-root/data/agent-runtime/ownership/writer.lock", + "user-root/data/agent-runtime/ipc-v17/discovery.json", + "user-root/logs/legacy.log", + "home/projects/workspace-demo/sessions/session-1/request-traces/trace.json" + ] +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/device_identity.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/device_identity.json new file mode 100644 index 0000000000..0e39e057c3 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/device_identity.json @@ -0,0 +1 @@ +{"device_id":"0123456789abcdef0123456789abcdef"} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/memories/MEMORY.md b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/memories/MEMORY.md new file mode 100644 index 0000000000..11384c5aef --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/memories/MEMORY.md @@ -0,0 +1,4 @@ +# Synthetic memory fixture + +This file verifies that file-backed memory is migrated independently of the +SQLite memory store. diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/metadata.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/metadata.json new file mode 100644 index 0000000000..70e4ec209f --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "session-1", + "workspacePath": "C:\\fixture\\workspace", + "title": "Synthetic migration fixture", + "createdAt": 1, + "updatedAt": 2 +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/request-traces/trace.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/request-traces/trace.json new file mode 100644 index 0000000000..36e6f637da --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/request-traces/trace.json @@ -0,0 +1 @@ +{"diagnostic":"must not migrate"} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/turns/turn-1.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/turns/turn-1.json new file mode 100644 index 0000000000..08612d0907 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/turns/turn-1.json @@ -0,0 +1 @@ +{"id":"turn-1","sessionId":"session-1","status":"completed","items":[]} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/remote_connect_persistence.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/remote_connect_persistence.json new file mode 100644 index 0000000000..7bb5c613c8 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/remote_connect_persistence.json @@ -0,0 +1,11 @@ +{ + "connections": [ + { + "bot_type": "telegram", + "display_name": "Synthetic bot", + "enabled": false, + "requires_reauthentication": true + } + ], + "account_remote_context": true +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/runtime-events/session-1.jsonl b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/runtime-events/session-1.jsonl new file mode 100644 index 0000000000..66f821cdc0 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/runtime-events/session-1.jsonl @@ -0,0 +1 @@ +{"sessionId":"session-1","turnId":"turn-1","event":"settled"} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/known_hosts b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/known_hosts new file mode 100644 index 0000000000..4ff70ff4a8 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/known_hosts @@ -0,0 +1 @@ +[example.invalid]:22 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixtureOnlyNotARealHostKey diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/remote_workspace.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/remote_workspace.json new file mode 100644 index 0000000000..0f64ab2ba8 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/remote_workspace.json @@ -0,0 +1,4 @@ +{ + "connectionId": "ssh-fixture@example.invalid:22", + "remotePath": "/srv/fixture-workspace" +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/ssh_connections.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/ssh_connections.json new file mode 100644 index 0000000000..0ebd6e24e9 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/ssh_connections.json @@ -0,0 +1,9 @@ +[ + { + "id": "ssh-fixture@example.invalid:22", + "host": "example.invalid", + "port": 22, + "username": "fixture", + "auth_type": "password" + } +] diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/agents/researcher.md b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/agents/researcher.md new file mode 100644 index 0000000000..66db5bbc14 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/agents/researcher.md @@ -0,0 +1,13 @@ +--- +id: researcher +name: Researcher +description: Synthetic fixture agent +kind: subagent +tools: + - Read +readonly: true +model: fast +schema_version: 1 +--- + +Inspect the supplied fixture files and summarize their relationships. diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/config/app.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/config/app.json new file mode 100644 index 0000000000..92f7e1e1c0 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/config/app.json @@ -0,0 +1,20 @@ +{ + "app": { + "language": "zh-CN", + "auto_update": false, + "telemetry": false + }, + "appearance": { + "theme_id": "bitfun-dark" + }, + "ai": { + "agent_profiles": { + "coding_shared": { + "enabled_skills": ["user::bitfun::user-skill"] + } + } + }, + "schema_version": 1, + "version": "0.2.19", + "last_modified": 0 +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/coordination.sql b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/coordination.sql new file mode 100644 index 0000000000..e37acd349a --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/coordination.sql @@ -0,0 +1,45 @@ +PRAGMA user_version = 2; +CREATE TABLE coordination_sessions ( + parent_session_id TEXT PRIMARY KEY, + next_auto_agent_seq INTEGER NOT NULL DEFAULT 1, + updated_at_ms INTEGER NOT NULL +); +CREATE TABLE agents ( + agent_pk INTEGER PRIMARY KEY AUTOINCREMENT, + parent_session_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + child_session_id TEXT, + next_bg_seq INTEGER NOT NULL DEFAULT 1, + state TEXT NOT NULL, + created_at_ms INTEGER NOT NULL +); +CREATE TABLE background_tasks ( + task_pk INTEGER PRIMARY KEY AUTOINCREMENT, + parent_session_id TEXT NOT NULL, + agent_pk INTEGER NOT NULL, + bg_task_id TEXT NOT NULL, + bg_ordinal INTEGER NOT NULL, + parent_dialog_turn_id TEXT NOT NULL, + parent_tool_call_id TEXT NOT NULL, + child_dialog_turn_id TEXT NOT NULL, + status TEXT NOT NULL, + error_code TEXT, + error_message TEXT, + execution_owner_token TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + terminal_at_ms INTEGER +); +CREATE TABLE swarm_trees (root_session_id TEXT PRIMARY KEY, created_at_ms INTEGER NOT NULL); +CREATE TABLE swarm_nodes ( + session_id TEXT PRIMARY KEY, + root_session_id TEXT NOT NULL, + parent_session_id TEXT, + agent_type TEXT NOT NULL, + depth INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL +); +INSERT INTO coordination_sessions VALUES ('session-1', 2, 1); +INSERT INTO agents VALUES (1, 'session-1', 'helper-1', 'session-child-1', 2, 'historical', 1); +INSERT INTO background_tasks VALUES (1, 'session-1', 1, 'bg-1', 1, 'turn-1', 'call-1', 'turn-child-1', 'completed', NULL, NULL, 'fixture-owner', 1, 2); +INSERT INTO swarm_trees VALUES ('session-1', 1); +INSERT INTO swarm_nodes VALUES ('session-child-1', 'session-1', 'session-1', 'researcher', 1, 1); diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/ipc-v17/discovery.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/ipc-v17/discovery.json new file mode 100644 index 0000000000..00b8780624 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/ipc-v17/discovery.json @@ -0,0 +1 @@ +{"pid":999999,"endpoint":"fixture","token":"not-a-secret-fixture"} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/ownership/writer.lock b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/ownership/writer.lock new file mode 100644 index 0000000000..5b3e049c6c --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/ownership/writer.lock @@ -0,0 +1 @@ +synthetic stale ownership marker diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/memories/memories.sql b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/memories/memories.sql new file mode 100644 index 0000000000..764d56cd62 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/memories/memories.sql @@ -0,0 +1,6 @@ +CREATE TABLE memories ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + created_at INTEGER NOT NULL +); +INSERT INTO memories VALUES ('memory-1', 'Synthetic migration fixture memory.', 1); diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/.builtin-manifest.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/.builtin-manifest.json new file mode 100644 index 0000000000..81385c1b7f --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/.builtin-manifest.json @@ -0,0 +1 @@ +{"version":1,"hash":"sha256:fixture"} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/index.html b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/index.html new file mode 100644 index 0000000000..eb2b200e8b --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/index.html @@ -0,0 +1 @@ +Legacy built-in code must be regenerated. diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/meta.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/meta.json new file mode 100644 index 0000000000..e81ed06d2d --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/meta.json @@ -0,0 +1 @@ +{"id":"builtin-gomoku","name":"Legacy built-in","version":1,"created_at":0,"updated_at":0} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/storage.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/storage.json new file mode 100644 index 0000000000..3de66b3860 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/builtin-gomoku/storage.json @@ -0,0 +1 @@ +{"wins":3,"losses":1} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/index.html b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/index.html new file mode 100644 index 0000000000..0975b01cea --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/index.html @@ -0,0 +1 @@ +
Synthetic fixture
diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/meta.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/meta.json new file mode 100644 index 0000000000..9c9b503d1a --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/meta.json @@ -0,0 +1,18 @@ +{ + "id": "custom-notes", + "name": "Custom Notes", + "description": "Synthetic user MiniApp", + "icon": "FileText", + "category": "productivity", + "tags": ["fixture"], + "version": 1, + "created_at": 1, + "updated_at": 2, + "permissions": { + "fs": { "read": ["{appdata}"], "write": ["{appdata}"] }, + "shell": { "allow": [] }, + "net": { "allow": [] }, + "node": { "enabled": false, "max_memory_mb": 128, "timeout_ms": 5000 } + }, + "ai_context": null +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/storage.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/storage.json new file mode 100644 index 0000000000..6910bb1c15 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/miniapps/custom-notes/storage.json @@ -0,0 +1 @@ +{"notes":[{"id":"note-1","title":"Fixture note"}]} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/workspace_data.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/workspace_data.json new file mode 100644 index 0000000000..737f564a96 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/workspace_data.json @@ -0,0 +1,14 @@ +{ + "workspaces": { + "workspace-1": { + "id": "workspace-1", + "name": "Fixture workspace", + "path": "C:\\fixture\\workspace", + "workspace_type": "local" + } + }, + "opened_workspace_ids": ["workspace-1"], + "current_workspace_id": "workspace-1", + "recent_workspaces": ["workspace-1"], + "saved_at": "2026-01-01T00:00:00Z" +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/skills/.system/builtin/SKILL.md b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/skills/.system/builtin/SKILL.md new file mode 100644 index 0000000000..5c01116d5e --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/skills/.system/builtin/SKILL.md @@ -0,0 +1,6 @@ +--- +name: builtin +description: Synthetic built-in skill that must never be migrated. +--- + +This directory is regenerated by the installed OpenBitFun version. diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/skills/user-skill/SKILL.md b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/skills/user-skill/SKILL.md new file mode 100644 index 0000000000..726bd125d3 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/skills/user-skill/SKILL.md @@ -0,0 +1,6 @@ +--- +name: user-skill +description: Synthetic user skill used by migration tests. +--- + +Read the fixture without executing scripts. From e59288d2edc0b95ec16b929072fc5f8f5c4211c8 Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 7 Sep 2026 21:46:36 +0800 Subject: [PATCH 2/7] feat(migration): Add offline migration engine Introduce a shared offline importer so product hosts can discover legacy data and coordinate migration without implementing storage operations in their UI or command handlers. - Define migration selections, plans, findings, progress, and reports. - Discover supported sources and resolve isolated source and target roots while keeping legacy data read-only. - Add locking, staging, journal recovery, cancellation, and rollback for domain imports that have not completed validation. - Snapshot committed SQLite data, including WAL contents, through SQLite backup operations instead of copying live database files. - Register the service crate and dependency boundaries, and cover recovery, source preservation, and lock contention with tests. Keep data conversion in owner adapters while the engine coordinates storage operations and records recoverable migration state. --- Cargo.lock | 21 + Cargo.toml | 2 + scripts/check-core-boundaries.test.mjs | 5 + .../explicit-test-topology.mjs | 5 + .../core-boundaries/rules/crate-layout.mjs | 1 + .../core-boundaries/rules/feature-rules.mjs | 9 +- scripts/product-identity-audit.mjs | 18 +- scripts/product-identity-audit.test.mjs | 14 + .../contracts/product-domains/Cargo.toml | 8 +- .../src/legacy_migration/mod.rs | 450 ++++++++++ .../contracts/product-domains/src/lib.rs | 3 + .../tests/legacy_migration_contracts.rs | 53 ++ src/crates/services/AGENTS-CN.md | 1 + src/crates/services/AGENTS.md | 1 + .../services/legacy-migration/Cargo.toml | 43 + .../legacy-migration/examples/dry_run.rs | 18 + .../services/legacy-migration/src/engine.rs | 815 ++++++++++++++++++ .../services/legacy-migration/src/error.rs | 76 ++ .../services/legacy-migration/src/lib.rs | 18 + .../services/legacy-migration/src/paths.rs | 121 +++ .../services/legacy-migration/src/probe.rs | 304 +++++++ .../services/legacy-migration/src/sqlite.rs | 61 ++ .../services/legacy-migration/src/storage.rs | 191 ++++ .../tests/migration_engine_contracts.rs | 321 +++++++ 24 files changed, 2552 insertions(+), 7 deletions(-) create mode 100644 src/crates/contracts/product-domains/src/legacy_migration/mod.rs create mode 100644 src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs create mode 100644 src/crates/services/legacy-migration/Cargo.toml create mode 100644 src/crates/services/legacy-migration/examples/dry_run.rs create mode 100644 src/crates/services/legacy-migration/src/engine.rs create mode 100644 src/crates/services/legacy-migration/src/error.rs create mode 100644 src/crates/services/legacy-migration/src/lib.rs create mode 100644 src/crates/services/legacy-migration/src/paths.rs create mode 100644 src/crates/services/legacy-migration/src/probe.rs create mode 100644 src/crates/services/legacy-migration/src/sqlite.rs create mode 100644 src/crates/services/legacy-migration/src/storage.rs create mode 100644 src/crates/services/legacy-migration/tests/migration_engine_contracts.rs diff --git a/Cargo.lock b/Cargo.lock index 6eb09e10f1..bac4d5a719 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6070,6 +6070,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "openbitfun-legacy-migration" +version = "1.0.0" +dependencies = [ + "chrono", + "dirs 6.0.0", + "fs2", + "hex", + "openbitfun-product-domains", + "openbitfun-services-core", + "rusqlite", + "semver", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.19", + "uuid", + "windows 0.61.3", +] + [[package]] name = "openbitfun-miniapp-market-server" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 18eaa6b17c..ded0458a89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "src/crates/adapters/transport", "src/crates/services/services-core", "src/crates/services/services-integrations", + "src/crates/services/legacy-migration", "src/crates/services/miniapp-market-service", "src/crates/services/skin-market-service", "src/crates/services/relay-service", @@ -197,6 +198,7 @@ urlencoding = "2.1" oxc = { version = "0.138.0", default-features = false, features = ["ast_visit", "codegen", "semantic", "transformer"] } oxc-parse = { package = "oxc", version = "0.138.0", default-features = false } sqlx = { version = "0.8", default-features = false } +rusqlite = { version = "0.32", default-features = false } # Tauri (desktop only) tauri = { version = "2.11", features = ["unstable", "macos-private-api", "tray-icon"] } diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index df0dd7086c..6d3ba14bc6 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: 'legacy_migration_contracts', + path: 'tests/legacy_migration_contracts.rs', + requiredFeatures: ['legacy-migration'], + }, { name: 'plugin_source_contracts', path: 'tests/plugin_source_contracts.rs', diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index 50e4e2cc54..d3026092bb 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -201,6 +201,11 @@ export const productDomainsIntegrationTestTargets = [ path: 'tests/miniapp_contracts.rs', requiredFeatures: ['miniapp'], }, + { + name: 'legacy_migration_contracts', + path: 'tests/legacy_migration_contracts.rs', + requiredFeatures: ['legacy-migration'], + }, { name: 'plugin_source_contracts', path: 'tests/plugin_source_contracts.rs', diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index b3fd8d30ae..1f550325e3 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -23,6 +23,7 @@ export const crateLayoutRules = [ { crateName: 'services-core', layer: 'services', path: 'src/crates/services/services-core' }, { crateName: 'services-integrations', layer: 'services', path: 'src/crates/services/services-integrations' }, + { crateName: 'legacy-migration', layer: 'services', path: 'src/crates/services/legacy-migration' }, { crateName: 'miniapp-market-service', layer: 'services', path: 'src/crates/services/miniapp-market-service' }, { crateName: 'skin-market-service', layer: 'services', path: 'src/crates/services/skin-market-service' }, { crateName: 'relay-service', layer: 'services', path: 'src/crates/services/relay-service' }, diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index bd9ae23f97..e85d8ece48 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -1743,7 +1743,14 @@ export const ownerCrateFeatureAssemblyRules = [ { manifestPath: 'src/crates/contracts/product-domains/Cargo.toml', reason: 'product-domains must keep product domain feature groups explicit and default-light', - requiredProductFullFeatures: ['appearance-market', 'plugin-source', 'miniapp', 'function-agents', 'external-sources'], + requiredProductFullFeatures: [ + 'appearance-market', + 'plugin-source', + 'miniapp', + 'function-agents', + 'external-sources', + 'legacy-migration', + ], }, ]; diff --git a/scripts/product-identity-audit.mjs b/scripts/product-identity-audit.mjs index 8144309428..89e5f33e49 100644 --- a/scripts/product-identity-audit.mjs +++ b/scripts/product-identity-audit.mjs @@ -3,11 +3,9 @@ /** * Prevent the retired product identity from returning to production sources. * - * The normal product is OpenBitFun-only. The only legacy exception is the - * exact legacy data-directory ignore entry for machine-local data retained - * across upgrades. - * A future one-time importer may add another deliberately narrow allowlist for - * its source adapters and fixtures. + * The normal product is OpenBitFun-only. Legacy exceptions are restricted to + * the exact data-directory ignore entry and the one-time migration documents, + * service boundary, and fixtures used for in-place upgrades. */ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, statSync } from 'node:fs'; @@ -21,6 +19,8 @@ const retiredProductToken = `${'bit'}${'fun'}`; const shortPrefix = `${'b'}${'f'}`; const productIdentityOwner = 'src/crates/contracts/core-types/src/product_identity.rs'; const retiredIdentityDataBoundaryFiles = new Set([ + 'OPENBITFUN_LEGACY_DATA_MIGRATION_IMPLEMENTATION_PLAN.md', + 'OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md', 'deploy/openbitfun-host/README.md', 'deploy/openbitfun-host/migrate-market-data-v1.py', 'src/apps/relay-server/README.md', @@ -31,7 +31,11 @@ const retiredIdentityDataBoundaryFiles = new Set([ 'src/apps/mobile/harmonyos/entry/src/main/ets/services/HarmonyUpgradeIdentityContract.ets', 'src/apps/mobile/harmonyos/entry/src/main/resources/base/profile/backup_config.json', ]); +const retiredIdentityDataBoundaryPrefixes = Object.freeze([ + 'src/crates/services/legacy-migration/', +]); const noncanonicalIdentityDataBoundaryFiles = new Set([ + 'OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md', 'deploy/openbitfun-host/migrate-market-data-v1.py', ]); @@ -56,6 +60,7 @@ const identityRules = Object.freeze([ && location.location === 'content' && location.lineText?.trim() === `.${retiredProductToken}/`, allowedFiles: retiredIdentityDataBoundaryFiles, + allowedFilePrefixes: retiredIdentityDataBoundaryPrefixes, }), Object.freeze({ id: 'retired-css-token-prefix', @@ -228,6 +233,9 @@ function collectMatches(value, location) { if (rule.allowedFiles?.has(location.file)) { continue; } + if (rule.allowedFilePrefixes?.some((prefix) => location.file.startsWith(prefix))) { + continue; + } if (rule.isViolation && !rule.isViolation(match[0])) { continue; } diff --git a/scripts/product-identity-audit.test.mjs b/scripts/product-identity-audit.test.mjs index bc070216fe..53c29c69f3 100644 --- a/scripts/product-identity-audit.test.mjs +++ b/scripts/product-identity-audit.test.mjs @@ -117,6 +117,20 @@ test('limits retired identity data to the one-time production migration boundary violationsFor(`const field = "${retiredField}";`, 'src/example.ts').length, 1, ); + assert.deepEqual( + violationsFor( + `const SOURCE_PRODUCT: &str = "${retiredLowerName}";`, + 'src/crates/services/legacy-migration/src/source.rs', + ), + [], + ); + assert.equal( + violationsFor( + `const SOURCE_PRODUCT: &str = "${retiredLowerName}";`, + 'src/crates/services/example/src/source.rs', + ).length, + 1, + ); }); test('allows retired Harmony identifiers only at the upgrade identity boundary', () => { diff --git a/src/crates/contracts/product-domains/Cargo.toml b/src/crates/contracts/product-domains/Cargo.toml index 2629d8b354..c462256e93 100644 --- a/src/crates/contracts/product-domains/Cargo.toml +++ b/src/crates/contracts/product-domains/Cargo.toml @@ -34,6 +34,11 @@ name = "miniapp_contracts" path = "tests/miniapp_contracts.rs" required-features = ["miniapp"] +[[test]] +name = "legacy_migration_contracts" +path = "tests/legacy_migration_contracts.rs" +required-features = ["legacy-migration"] + [dependencies] serde = { workspace = true } serde_json = { workspace = true } @@ -54,7 +59,8 @@ plugin-source = ["hex", "sha2"] miniapp = ["dirs", "hex", "sha2", "which"] function-agents = ["log"] external-sources = ["hex", "hmac", "sha2", "url"] -product-full = ["appearance-market", "plugin-source", "miniapp", "function-agents", "external-sources"] +legacy-migration = [] +product-full = ["appearance-market", "plugin-source", "miniapp", "function-agents", "external-sources", "legacy-migration"] # Derive `ts_rs::TS` on the wire types (permissions, etc.) for downstream # TypeScript binding export. Orthogonal to the product feature groups; does not # pull any optional integration dependency. diff --git a/src/crates/contracts/product-domains/src/legacy_migration/mod.rs b/src/crates/contracts/product-domains/src/legacy_migration/mod.rs new file mode 100644 index 0000000000..5a050cfe16 --- /dev/null +++ b/src/crates/contracts/product-domains/src/legacy_migration/mod.rs @@ -0,0 +1,450 @@ +//! Stable, platform-agnostic contracts for importing retired product data. +//! +//! This module describes persisted state and the handoff protocol. Filesystem, +//! process, SQLite, credential-vault, and UI behavior belong to service and app +//! owners. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +pub const CURRENT_MIGRATION_FORMAT_VERSION: u32 = 1; +pub const CURRENT_MIGRATOR_PROTOCOL_VERSION: u32 = 1; +pub const MIN_MIGRATOR_PROTOCOL_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationGroupId { + SettingsAndCredentials, + AgentsSkillsAndMiniapps, + WorkspacesSessionsAndTasks, + Memory, + RemoteConnectionsAndDevices, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationDomainId { + Settings, + Credentials, + Skills, + Miniapps, + Agents, + WorkspaceSessions, + AgentCoordination, + StructuredMemory, + FileMemory, + RemoteConnectDevices, + RemoteSsh, + CrossReferenceRepair, +} + +impl MigrationDomainId { + pub const fn group(self) -> Option { + match self { + Self::Settings | Self::Credentials => Some(MigrationGroupId::SettingsAndCredentials), + Self::Skills | Self::Miniapps | Self::Agents => { + Some(MigrationGroupId::AgentsSkillsAndMiniapps) + } + Self::WorkspaceSessions | Self::AgentCoordination => { + Some(MigrationGroupId::WorkspacesSessionsAndTasks) + } + Self::StructuredMemory | Self::FileMemory => Some(MigrationGroupId::Memory), + Self::RemoteConnectDevices | Self::RemoteSsh => { + Some(MigrationGroupId::RemoteConnectionsAndDevices) + } + Self::CrossReferenceRepair => None, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationSelection { + pub groups: BTreeSet, +} + +impl MigrationSelection { + pub fn all() -> Self { + Self { + groups: BTreeSet::from([ + MigrationGroupId::SettingsAndCredentials, + MigrationGroupId::AgentsSkillsAndMiniapps, + MigrationGroupId::WorkspacesSessionsAndTasks, + MigrationGroupId::Memory, + MigrationGroupId::RemoteConnectionsAndDevices, + ]), + } + } + + /// Expand the five user-visible groups into their fixed dependency order. + pub fn expanded_domains(&self) -> Vec { + let mut domains = Vec::new(); + if self + .groups + .contains(&MigrationGroupId::SettingsAndCredentials) + { + domains.extend([MigrationDomainId::Settings, MigrationDomainId::Credentials]); + } + if self + .groups + .contains(&MigrationGroupId::AgentsSkillsAndMiniapps) + { + domains.extend([ + MigrationDomainId::Skills, + MigrationDomainId::Miniapps, + MigrationDomainId::Agents, + ]); + } + if self + .groups + .contains(&MigrationGroupId::WorkspacesSessionsAndTasks) + { + domains.extend([ + MigrationDomainId::WorkspaceSessions, + MigrationDomainId::AgentCoordination, + ]); + } + if self.groups.contains(&MigrationGroupId::Memory) { + domains.extend([ + MigrationDomainId::StructuredMemory, + MigrationDomainId::FileMemory, + ]); + } + if self + .groups + .contains(&MigrationGroupId::RemoteConnectionsAndDevices) + { + domains.extend([ + MigrationDomainId::RemoteConnectDevices, + MigrationDomainId::RemoteSsh, + ]); + } + if !domains.is_empty() { + domains.push(MigrationDomainId::CrossReferenceRepair); + } + domains + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LegacyRootDescriptor { + pub kind: LegacyRootKind, + pub display_path: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LegacyRootKind { + #[default] + ProductData, + ProductHome, + RemoteSsh, + ManagedWebview, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LegacySourceDescriptor { + pub source_id: String, + pub source_fingerprint: String, + pub product_id: String, + pub product_version: String, + pub platform: String, + pub roots: Vec, + pub readable: bool, + pub supported: bool, + pub approximate_bytes: u64, + pub already_migrated: bool, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FindingSeverity { + #[default] + Info, + Warning, + Blocking, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ScanFinding { + pub domain: MigrationDomainId, + pub code: String, + pub severity: FindingSeverity, + pub entity_count: u64, + pub logical_bytes: u64, + pub source_schema: Option, + pub migratable: bool, + pub detail: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConflictResolution { + #[default] + TargetWins, + SourceImported, + SourceRemapped, + DuplicateSkipped, + ItemSkipped, + RequiresUserAction, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationConflict { + pub domain: MigrationDomainId, + pub code: String, + pub source_summary: String, + pub target_summary: String, + pub resolution: ConflictResolution, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationPlanStep { + pub sequence: u32, + pub domain: MigrationDomainId, + pub estimated_write_bytes: u64, + pub source_schema: Option, + pub target_schema: Option, + pub dependencies: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationPlan { + pub format_version: u32, + pub run_id: String, + pub source_fingerprint: String, + pub selection: MigrationSelection, + pub steps: Vec, + pub findings: Vec, + pub conflicts: Vec, + pub estimated_write_bytes: u64, + pub plan_hash: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationPhase { + #[default] + Discover, + Scan, + Plan, + Acquire, + Stage, + ValidateStage, + Commit, + ValidateCommit, + Finalize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationProgressEvent { + pub run_id: String, + pub domain: Option, + pub phase: MigrationPhase, + pub processed: u64, + pub total: u64, + pub safe_to_cancel: bool, + pub code: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationRunStatus { + #[default] + Discovered, + Scanned, + Planned, + WaitingForProcesses, + Staging, + ValidatingStage, + Committing, + ValidatingCommit, + Completed, + CompletedWithWarnings, + Cancelled, + FailedRecoverable, + FailedManualActionRequired, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationDomainState { + #[default] + NotStarted, + Staged, + Committed, + Verified, + Failed, + Skipped, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationDomainResult { + pub domain: MigrationDomainId, + pub state: MigrationDomainState, + pub imported: u64, + pub skipped: u64, + pub conflicts: u64, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationDiagnostic { + pub code: String, + pub severity: FindingSeverity, + pub domain: Option, + pub relative_path: Option, + pub message: String, + pub action: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationRunReport { + pub format_version: u32, + pub run_id: String, + pub source_fingerprint: String, + pub plan_hash: String, + pub status: MigrationRunStatus, + pub started_at_ms: i64, + pub finished_at_ms: Option, + pub domain_results: Vec, + pub diagnostics: Vec, + pub requires_reauthentication: Vec, + pub requires_relocation: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationPromptChoice { + #[default] + Unset, + MigrateNow, + RemindLater, + DoNotRemind, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationOnboardingState { + pub format_version: u32, + pub source_fingerprint: String, + pub detected_at_ms: Option, + pub choice: MigrationPromptChoice, + pub last_prompted_version: Option, + pub run_id: Option, + pub handled_run_id: Option, +} + +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum MigratorRequestMode { + #[default] + Onboarding, + Execute, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigratorRequestOrigin { + #[default] + FirstLaunch, + Settings, + Installer, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigratorProtocolCapabilities { + pub protocol_version: u32, + pub minimum_compatible_version: u32, + pub supported_modes: BTreeSet, +} + +impl MigratorProtocolCapabilities { + pub fn current() -> Self { + Self { + protocol_version: CURRENT_MIGRATOR_PROTOCOL_VERSION, + minimum_compatible_version: MIN_MIGRATOR_PROTOCOL_VERSION, + supported_modes: BTreeSet::from([ + MigratorRequestMode::Onboarding, + MigratorRequestMode::Execute, + ]), + } + } + + pub fn accepts(&self, version: u32, mode: MigratorRequestMode) -> bool { + version >= self.minimum_compatible_version + && version <= self.protocol_version + && self.supported_modes.contains(&mode) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigratorHandoffRequest { + pub protocol_version: u32, + pub mode: MigratorRequestMode, + pub origin: MigratorRequestOrigin, + pub run_id: String, + pub nonce: String, + pub source_id: Option, + pub source_fingerprint: Option, + pub selection: MigrationSelection, + pub caller_process_id: u32, + pub product_id: String, + pub release_channel: String, + pub created_at_ms: i64, + pub expires_at_ms: i64, +} + +impl MigratorHandoffRequest { + pub fn is_expired_at(&self, now_ms: i64) -> bool { + now_ms > self.expires_at_ms + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationRemoteStance { + #[default] + ControllerLocal, + UnsupportedRemoteControl, + UnsupportedDetachedDispatch, +} + +/// Append-only recovery evidence emitted by the offline migration engine. +/// +/// New fields must remain additive and defaultable because a newer migrator can +/// resume a journal created by an older installed build. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationJournalEvent { + pub format_version: u32, + pub sequence: u64, + pub recorded_at_ms: i64, + pub run_id: String, + pub status: MigrationRunStatus, + pub phase: MigrationPhase, + pub domain: Option, + pub domain_state: Option, + pub code: String, +} + +impl Default for MigrationDomainId { + fn default() -> Self { + Self::Settings + } +} diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index dfc4f377c4..8f9359d800 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -53,3 +53,6 @@ pub mod miniapp; #[cfg(feature = "function-agents")] pub mod function_agents; + +#[cfg(feature = "legacy-migration")] +pub mod legacy_migration; diff --git a/src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs b/src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs new file mode 100644 index 0000000000..6224c6c873 --- /dev/null +++ b/src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs @@ -0,0 +1,53 @@ +use openbitfun_product_domains::legacy_migration::{ + MigrationDomainId, MigrationGroupId, MigrationOnboardingState, MigrationPromptChoice, + MigrationSelection, MigratorHandoffRequest, MigratorProtocolCapabilities, MigratorRequestMode, + CURRENT_MIGRATOR_PROTOCOL_VERSION, +}; +use std::collections::BTreeSet; + +#[test] +fn session_group_always_expands_coordination_database_dependency() { + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::WorkspacesSessionsAndTasks]), + }; + + assert_eq!( + selection.expanded_domains(), + vec![ + MigrationDomainId::WorkspaceSessions, + MigrationDomainId::AgentCoordination, + MigrationDomainId::CrossReferenceRepair, + ] + ); +} + +#[test] +fn persisted_onboarding_shape_accepts_old_payloads() { + let state: MigrationOnboardingState = + serde_json::from_str(r#"{"choice":"remind_later"}"#).expect("additive fields must default"); + + assert_eq!(state.choice, MigrationPromptChoice::RemindLater); + assert_eq!(state.format_version, 0); + assert!(state.run_id.is_none()); +} + +#[test] +fn handoff_rejects_expired_or_future_protocol_requests() { + let request: MigratorHandoffRequest = serde_json::from_value(serde_json::json!({ + "protocolVersion": CURRENT_MIGRATOR_PROTOCOL_VERSION, + "mode": "onboarding", + "runId": "run-1", + "nonce": "nonce-1", + "createdAtMs": 10, + "expiresAtMs": 20 + })) + .expect("request should accept additive defaults"); + + let capabilities = MigratorProtocolCapabilities::current(); + assert!(capabilities.accepts(request.protocol_version, request.mode)); + assert!(!capabilities.accepts( + CURRENT_MIGRATOR_PROTOCOL_VERSION + 1, + MigratorRequestMode::Onboarding + )); + assert!(request.is_expired_at(21)); +} diff --git a/src/crates/services/AGENTS-CN.md b/src/crates/services/AGENTS-CN.md index 97c644d195..bbf721940c 100644 --- a/src/crates/services/AGENTS-CN.md +++ b/src/crates/services/AGENTS-CN.md @@ -10,6 +10,7 @@ |---|---|---| | `services-core` | 不包含产品组装决策的本地 service primitive,包括 session storage、metadata store CRUD/index rebuild、metadata 构造/计数/索引/字段 mutation、lineage 规则和 JSON file IO | [AGENTS.md](services-core/AGENTS.md) | | `services-integrations` | MCP、git、remote、file watch、MiniApp runtime、产品领域 port 具体实现,以及平台无关的 Remote Connect primitives | [AGENTS.md](services-integrations/AGENTS.md) | +| `legacy-migration` | 离线旧产品发现、加锁、一致性 SQLite 快照、暂存、journal 恢复和 owner adapter 编排 | — | | `relay-service` | standalone 与 embedded 宿主共享的 Remote Connect relay 状态、存储及 HTTP/WebSocket 路由 | [AGENTS.md](relay-service/AGENTS.md) | | `page-function-runtime` | OpenBitFun Pages 嵌入式 JS Page Function runtime(rquickjs) | [AGENTS.md](page-function-runtime/AGENTS.md) | | `terminal` | PTY、shell integration 与 terminal session infrastructure | [AGENTS.md](terminal/AGENTS.md) | diff --git a/src/crates/services/AGENTS.md b/src/crates/services/AGENTS.md index 79530cdfc4..2edd3bceb8 100644 --- a/src/crates/services/AGENTS.md +++ b/src/crates/services/AGENTS.md @@ -13,6 +13,7 @@ OS/network capabilities. |---|---|---| | `services-core` | Reusable local service primitives, process-wide TLS provider selection, managed process-tree lifecycle, filesystem helpers, session storage layout/indexing/deletion, metadata store CRUD/index rebuild, metadata construction/counter/index/field mutation/lineage rules, and JSON file IO without product assembly decisions | [AGENTS.md](services-core/AGENTS.md) | | `services-integrations` | Concrete MCP, git, remote, file-watch, MiniApp runtime, review-platform provider service, product-domain port implementations, and platform-neutral Remote Connect primitives | [AGENTS.md](services-integrations/AGENTS.md) | +| `legacy-migration` | Offline retired-product discovery, locking, consistent SQLite snapshots, staging, journal recovery, and owner-adapter orchestration | — | | `miniapp-market-service` | Concrete SQLite, artifact storage, GitHub OAuth, package validation, and HTTP behavior for the MiniApp market | [README.md](miniapp-market-service/README.md) | | `skin-market-service` | Concrete SQLite, artifact storage, Appearance package validation, review, and HTTP behavior for the Skin market | [README.md](skin-market-service/README.md) | | `relay-service` | Reusable Remote Connect relay state, storage, and HTTP/WebSocket routes shared by standalone and embedded hosts | [AGENTS.md](relay-service/AGENTS.md) | diff --git a/src/crates/services/legacy-migration/Cargo.toml b/src/crates/services/legacy-migration/Cargo.toml new file mode 100644 index 0000000000..59513b03cc --- /dev/null +++ b/src/crates/services/legacy-migration/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "openbitfun-legacy-migration" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Offline legacy BitFun data migration service" +autotests = false + +[lib] +name = "openbitfun_legacy_migration" +crate-type = ["rlib"] + +[[example]] +name = "legacy-migration-dry-run" +path = "examples/dry_run.rs" + +[[test]] +name = "migration_engine_contracts" +path = "tests/migration_engine_contracts.rs" + +[dependencies] +chrono = { workspace = true } +dirs = { workspace = true } +fs2 = { workspace = true } +hex = { workspace = true } +openbitfun-product-domains = { path = "../../contracts/product-domains", features = ["legacy-migration"] } +openbitfun-services-core = { path = "../services-core", features = ["product-identity"] } +rusqlite = { workspace = true, features = ["backup", "bundled"] } +semver = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true, features = ["Win32_Storage_FileSystem"] } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/crates/services/legacy-migration/examples/dry_run.rs b/src/crates/services/legacy-migration/examples/dry_run.rs new file mode 100644 index 0000000000..f3edf935a6 --- /dev/null +++ b/src/crates/services/legacy-migration/examples/dry_run.rs @@ -0,0 +1,18 @@ +use openbitfun_legacy_migration::{probe_legacy_source, MigrationRoots, ProbeLimits}; + +fn main() { + let result = MigrationRoots::resolve_current_user() + .and_then(|roots| probe_legacy_source(&roots, ProbeLimits::default())); + match result { + Ok(source) => { + println!( + "{}", + serde_json::to_string_pretty(&source).expect("probe result should serialize") + ); + } + Err(error) => { + eprintln!("Legacy migration dry run failed: {error}"); + std::process::exit(1); + } + } +} diff --git a/src/crates/services/legacy-migration/src/engine.rs b/src/crates/services/legacy-migration/src/engine.rs new file mode 100644 index 0000000000..90498da954 --- /dev/null +++ b/src/crates/services/legacy-migration/src/engine.rs @@ -0,0 +1,815 @@ +use crate::{ + atomic_write_json, probe_legacy_source, LegacyMigrationError, LegacyMigrationResult, + MigrationLayout, MigrationLock, MigrationRoots, ProbeLimits, +}; +use openbitfun_product_domains::legacy_migration::{ + FindingSeverity, LegacySourceDescriptor, MigrationConflict, MigrationDiagnostic, + MigrationDomainId, MigrationDomainResult, MigrationDomainState, MigrationJournalEvent, + MigrationPhase, MigrationPlan, MigrationPlanStep, MigrationProgressEvent, MigrationRunReport, + MigrationRunStatus, MigrationSelection, CURRENT_MIGRATION_FORMAT_VERSION, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DomainScan { + pub finding: openbitfun_product_domains::legacy_migration::ScanFinding, + pub conflicts: Vec, + pub target_schema: Option, + pub dependencies: Vec, +} + +impl DomainScan { + pub fn estimated_write_bytes(&self) -> u64 { + self.finding.logical_bytes + } +} + +pub struct DomainContext<'a> { + pub roots: &'a MigrationRoots, + pub layout: &'a MigrationLayout, + pub plan: &'a MigrationPlan, + pub step: &'a MigrationPlanStep, +} + +/// Owner-provided legacy reader, converter, writer, and validator. +/// +/// `commit` must be idempotent for the same immutable plan. A process can die +/// after the owner writes its target but before the engine records completion, +/// so recovery is allowed to call `commit` again. Implementations must never +/// execute imported content and must keep the legacy source read-only. +pub trait LegacyDomainAdapter: Send + Sync { + fn domain(&self) -> MigrationDomainId; + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult; + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult; + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()>; + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()>; + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()>; + + fn rollback_unverified(&self, _context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CrashPoint { + AfterPlanPersisted, + AfterLockAcquired, + BeforeStage(MigrationDomainId), + AfterStage(MigrationDomainId), + AfterStageValidated(MigrationDomainId), + BeforeCommit(MigrationDomainId), + AfterCommit(MigrationDomainId), + AfterCommitValidated(MigrationDomainId), + BeforeFinalize, +} + +pub trait CrashInjector { + fn should_crash(&self, point: CrashPoint) -> bool; +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct NoCrashInjection; + +impl CrashInjector for NoCrashInjection { + fn should_crash(&self, _point: CrashPoint) -> bool { + false + } +} + +#[derive(Debug, Clone, Default)] +pub struct CancellationToken { + cancelled: Arc, +} + +impl CancellationToken { + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + } + + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Acquire) + } + + fn check(&self) -> LegacyMigrationResult<()> { + if self.is_cancelled() { + Err(LegacyMigrationError::Cancelled) + } else { + Ok(()) + } + } +} + +pub struct MigrationEngine { + roots: MigrationRoots, + adapters: BTreeMap>, +} + +impl MigrationEngine { + pub fn new( + roots: MigrationRoots, + adapters: impl IntoIterator>, + ) -> LegacyMigrationResult { + roots.validate_distinct()?; + let mut by_domain = BTreeMap::new(); + for adapter in adapters { + let domain = adapter.domain(); + if by_domain.insert(domain, adapter).is_some() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "duplicate migration adapter for {domain:?}" + ))); + } + } + Ok(Self { + roots, + adapters: by_domain, + }) + } + + pub fn scan( + &self, + selection: &MigrationSelection, + cancellation: &CancellationToken, + ) -> LegacyMigrationResult> { + let mut scans = Vec::new(); + for domain in selection.expanded_domains() { + cancellation.check()?; + let adapter = self.adapter(domain)?; + let scan = adapter + .scan(&self.roots) + .map_err(|error| domain_error(domain, error))?; + if scan.finding.domain != domain { + return Err(LegacyMigrationError::InvalidPlan(format!( + "adapter {domain:?} returned a finding for {:?}", + scan.finding.domain + ))); + } + scans.push(scan); + } + Ok(scans) + } + + pub fn plan( + &self, + source: &LegacySourceDescriptor, + selection: MigrationSelection, + cancellation: &CancellationToken, + ) -> LegacyMigrationResult { + if !source.readable || !source.supported { + return Err(LegacyMigrationError::UnsupportedSource( + "legacy source is not readable and supported".to_string(), + )); + } + let scans = self.scan(&selection, cancellation)?; + let selected = selection + .expanded_domains() + .into_iter() + .collect::>(); + let mut steps = Vec::with_capacity(scans.len()); + let mut findings = Vec::with_capacity(scans.len()); + let mut conflicts = Vec::new(); + let mut estimated_write_bytes = 0u64; + + for (index, scan) in scans.into_iter().enumerate() { + for dependency in &scan.dependencies { + if !selected.contains(dependency) { + return Err(LegacyMigrationError::InvalidPlan(format!( + "{:?} requires unselected domain {dependency:?}", + scan.finding.domain + ))); + } + } + estimated_write_bytes = + estimated_write_bytes.saturating_add(scan.estimated_write_bytes()); + steps.push(MigrationPlanStep { + sequence: u32::try_from(index + 1).unwrap_or(u32::MAX), + domain: scan.finding.domain, + estimated_write_bytes: scan.estimated_write_bytes(), + source_schema: scan.finding.source_schema.clone(), + target_schema: scan.target_schema, + dependencies: scan.dependencies, + }); + findings.push(scan.finding); + conflicts.extend(scan.conflicts); + } + + let mut plan = MigrationPlan { + format_version: CURRENT_MIGRATION_FORMAT_VERSION, + run_id: uuid::Uuid::new_v4().to_string(), + source_fingerprint: source.source_fingerprint.clone(), + selection, + steps, + findings, + conflicts, + estimated_write_bytes, + plan_hash: String::new(), + }; + plan.plan_hash = compute_plan_hash(&plan)?; + Ok(plan) + } + + pub fn execute( + &self, + plan: &MigrationPlan, + cancellation: &CancellationToken, + crash_injector: &dyn CrashInjector, + ) -> LegacyMigrationResult { + self.execute_with_progress(plan, cancellation, crash_injector, |_| {}) + } + + pub fn execute_with_progress( + &self, + plan: &MigrationPlan, + cancellation: &CancellationToken, + crash_injector: &dyn CrashInjector, + mut progress: impl FnMut(MigrationProgressEvent), + ) -> LegacyMigrationResult { + self.validate_plan(plan)?; + let layout = MigrationLayout::new(&self.roots, &plan.run_id); + layout.initialize()?; + persist_or_validate_plan(&layout, plan)?; + inject(crash_injector, CrashPoint::AfterPlanPersisted)?; + + cancellation.check()?; + let _lock = MigrationLock::acquire(&layout)?; + inject(crash_injector, CrashPoint::AfterLockAcquired)?; + let current_source = + probe_legacy_source(&self.roots, ProbeLimits::default())?.ok_or_else(|| { + LegacyMigrationError::InvalidPlan( + "legacy source disappeared after the plan was created".to_string(), + ) + })?; + if current_source.source_fingerprint != plan.source_fingerprint { + return Err(LegacyMigrationError::InvalidPlan( + "legacy source changed after the plan was created".to_string(), + )); + } + + let mut report = load_or_create_report(&layout, plan)?; + if matches!( + report.status, + MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings + ) { + return Ok(report); + } + normalize_report(plan, &mut report); + let mut journal_sequence = journal_sequence(&layout)?; + transition( + &layout, + &mut report, + &mut journal_sequence, + MigrationRunStatus::Staging, + MigrationPhase::Acquire, + None, + None, + "lock_acquired", + )?; + + for (index, step) in plan.steps.iter().enumerate() { + let result_index = report + .domain_results + .iter() + .position(|result| result.domain == step.domain) + .expect("normalized report includes every plan step"); + if report.domain_results[result_index].state == MigrationDomainState::Verified { + continue; + } + let context = DomainContext { + roots: &self.roots, + layout: &layout, + plan, + step, + }; + let adapter = self.adapter(step.domain)?; + + let recovered_state = report.domain_results[result_index].state; + if matches!( + recovered_state, + MigrationDomainState::NotStarted | MigrationDomainState::Failed + ) { + cancellation.check().map_err(|error| { + let _ = record_cancelled(&layout, &mut report, &mut journal_sequence); + error + })?; + inject(crash_injector, CrashPoint::BeforeStage(step.domain))?; + emit_progress( + &mut progress, + plan, + step, + MigrationPhase::Stage, + index, + true, + "staging_domain", + ); + transition( + &layout, + &mut report, + &mut journal_sequence, + MigrationRunStatus::Staging, + MigrationPhase::Stage, + Some(step.domain), + Some(MigrationDomainState::NotStarted), + "stage_started", + )?; + let mut staged = match adapter.stage(&context) { + Ok(staged) => staged, + Err(error) => { + record_domain_failure( + &layout, + &mut report, + &mut journal_sequence, + result_index, + step.domain, + MigrationPhase::Stage, + )?; + let _ = adapter.rollback_unverified(&context); + return Err(domain_error(step.domain, error)); + } + }; + if staged.domain != step.domain { + return Err(LegacyMigrationError::InvalidPlan(format!( + "adapter {:?} staged a result for {:?}", + step.domain, staged.domain + ))); + } + staged.state = MigrationDomainState::Staged; + report.domain_results[result_index] = staged; + persist_report(&layout, &report)?; + journal( + &layout, + &report, + &mut journal_sequence, + MigrationPhase::Stage, + Some(step.domain), + Some(MigrationDomainState::Staged), + "stage_completed", + )?; + inject(crash_injector, CrashPoint::AfterStage(step.domain))?; + } + + if report.domain_results[result_index].state == MigrationDomainState::Staged { + cancellation.check().map_err(|error| { + let _ = record_cancelled(&layout, &mut report, &mut journal_sequence); + error + })?; + transition( + &layout, + &mut report, + &mut journal_sequence, + MigrationRunStatus::ValidatingStage, + MigrationPhase::ValidateStage, + Some(step.domain), + Some(MigrationDomainState::Staged), + "stage_validation_started", + )?; + if let Err(error) = adapter.validate_stage(&context) { + record_domain_failure( + &layout, + &mut report, + &mut journal_sequence, + result_index, + step.domain, + MigrationPhase::ValidateStage, + )?; + let _ = adapter.rollback_unverified(&context); + return Err(domain_error(step.domain, error)); + } + journal( + &layout, + &report, + &mut journal_sequence, + MigrationPhase::ValidateStage, + Some(step.domain), + Some(MigrationDomainState::Staged), + "stage_validated", + )?; + inject(crash_injector, CrashPoint::AfterStageValidated(step.domain))?; + } + + if report.domain_results[result_index].state == MigrationDomainState::Staged { + cancellation.check().map_err(|error| { + let _ = record_cancelled(&layout, &mut report, &mut journal_sequence); + error + })?; + transition( + &layout, + &mut report, + &mut journal_sequence, + MigrationRunStatus::Committing, + MigrationPhase::Commit, + Some(step.domain), + Some(MigrationDomainState::Staged), + "commit_intent", + )?; + inject(crash_injector, CrashPoint::BeforeCommit(step.domain))?; + if let Err(error) = adapter.commit(&context) { + record_domain_failure( + &layout, + &mut report, + &mut journal_sequence, + result_index, + step.domain, + MigrationPhase::Commit, + )?; + let _ = adapter.rollback_unverified(&context); + return Err(domain_error(step.domain, error)); + } + inject(crash_injector, CrashPoint::AfterCommit(step.domain))?; + report.domain_results[result_index].state = MigrationDomainState::Committed; + persist_report(&layout, &report)?; + journal( + &layout, + &report, + &mut journal_sequence, + MigrationPhase::Commit, + Some(step.domain), + Some(MigrationDomainState::Committed), + "commit_completed", + )?; + } + + if report.domain_results[result_index].state == MigrationDomainState::Committed { + transition( + &layout, + &mut report, + &mut journal_sequence, + MigrationRunStatus::ValidatingCommit, + MigrationPhase::ValidateCommit, + Some(step.domain), + Some(MigrationDomainState::Committed), + "commit_validation_started", + )?; + if let Err(error) = adapter.validate_commit(&context) { + record_domain_failure( + &layout, + &mut report, + &mut journal_sequence, + result_index, + step.domain, + MigrationPhase::ValidateCommit, + )?; + return Err(domain_error(step.domain, error)); + } + report.domain_results[result_index].state = MigrationDomainState::Verified; + persist_report(&layout, &report)?; + journal( + &layout, + &report, + &mut journal_sequence, + MigrationPhase::ValidateCommit, + Some(step.domain), + Some(MigrationDomainState::Verified), + "commit_verified", + )?; + inject( + crash_injector, + CrashPoint::AfterCommitValidated(step.domain), + )?; + } + } + + inject(crash_injector, CrashPoint::BeforeFinalize)?; + report.finished_at_ms = Some(now_ms()); + let has_warnings = report.diagnostics.iter().any(|diagnostic| { + matches!( + diagnostic.severity, + FindingSeverity::Warning | FindingSeverity::Blocking + ) + }) || report + .domain_results + .iter() + .any(|result| !result.warnings.is_empty()); + let final_status = if has_warnings { + MigrationRunStatus::CompletedWithWarnings + } else { + MigrationRunStatus::Completed + }; + transition( + &layout, + &mut report, + &mut journal_sequence, + final_status, + MigrationPhase::Finalize, + None, + None, + "migration_completed", + )?; + emit_progress( + &mut progress, + plan, + plan.steps.last().unwrap_or(&MigrationPlanStep::default()), + MigrationPhase::Finalize, + plan.steps.len(), + true, + "migration_completed", + ); + Ok(report) + } + + fn adapter( + &self, + domain: MigrationDomainId, + ) -> LegacyMigrationResult<&dyn LegacyDomainAdapter> { + self.adapters.get(&domain).map(Box::as_ref).ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!( + "no migration adapter registered for {domain:?}" + )) + }) + } + + fn validate_plan(&self, plan: &MigrationPlan) -> LegacyMigrationResult<()> { + if plan.format_version != CURRENT_MIGRATION_FORMAT_VERSION { + return Err(LegacyMigrationError::InvalidPlan(format!( + "unsupported plan format {}", + plan.format_version + ))); + } + if plan.run_id.is_empty() || plan.source_fingerprint.is_empty() { + return Err(LegacyMigrationError::InvalidPlan( + "plan identity is incomplete".to_string(), + )); + } + if compute_plan_hash(plan)? != plan.plan_hash { + return Err(LegacyMigrationError::InvalidPlan( + "plan hash does not match the immutable plan".to_string(), + )); + } + let expected = plan.selection.expanded_domains(); + let actual = plan + .steps + .iter() + .map(|step| step.domain) + .collect::>(); + if expected != actual { + return Err(LegacyMigrationError::InvalidPlan( + "plan steps do not match the selected dependency order".to_string(), + )); + } + for (index, step) in plan.steps.iter().enumerate() { + if step.sequence != u32::try_from(index + 1).unwrap_or(u32::MAX) { + return Err(LegacyMigrationError::InvalidPlan( + "plan step sequence is not contiguous".to_string(), + )); + } + self.adapter(step.domain)?; + } + Ok(()) + } +} + +pub fn compute_plan_hash(plan: &MigrationPlan) -> LegacyMigrationResult { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct HashInput<'a> { + format_version: u32, + source_fingerprint: &'a str, + selection: &'a MigrationSelection, + steps: &'a [MigrationPlanStep], + findings: &'a [openbitfun_product_domains::legacy_migration::ScanFinding], + conflicts: &'a [MigrationConflict], + estimated_write_bytes: u64, + } + + let input = HashInput { + format_version: plan.format_version, + source_fingerprint: &plan.source_fingerprint, + selection: &plan.selection, + steps: &plan.steps, + findings: &plan.findings, + conflicts: &plan.conflicts, + estimated_write_bytes: plan.estimated_write_bytes, + }; + let bytes = serde_json::to_vec(&input) + .map_err(|error| LegacyMigrationError::json("", error))?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} + +fn persist_or_validate_plan( + layout: &MigrationLayout, + plan: &MigrationPlan, +) -> LegacyMigrationResult<()> { + if let Some(existing) = layout.read_json::(&layout.plan_path())? { + if existing != *plan { + return Err(LegacyMigrationError::InvalidPlan( + "run id already has a different immutable plan".to_string(), + )); + } + return Ok(()); + } + atomic_write_json(&layout.plan_path(), plan) +} + +fn load_or_create_report( + layout: &MigrationLayout, + plan: &MigrationPlan, +) -> LegacyMigrationResult { + if let Some(report) = layout.read_json::(&layout.report_path())? { + if report.run_id != plan.run_id + || report.source_fingerprint != plan.source_fingerprint + || report.plan_hash != plan.plan_hash + { + return Err(LegacyMigrationError::InvalidPlan( + "persisted report does not belong to this plan".to_string(), + )); + } + return Ok(report); + } + let report = MigrationRunReport { + format_version: CURRENT_MIGRATION_FORMAT_VERSION, + run_id: plan.run_id.clone(), + source_fingerprint: plan.source_fingerprint.clone(), + plan_hash: plan.plan_hash.clone(), + status: MigrationRunStatus::Planned, + started_at_ms: now_ms(), + domain_results: plan + .steps + .iter() + .map(|step| MigrationDomainResult { + domain: step.domain, + ..MigrationDomainResult::default() + }) + .collect(), + ..MigrationRunReport::default() + }; + persist_report(layout, &report)?; + Ok(report) +} + +fn normalize_report(plan: &MigrationPlan, report: &mut MigrationRunReport) { + for step in &plan.steps { + if !report + .domain_results + .iter() + .any(|result| result.domain == step.domain) + { + report.domain_results.push(MigrationDomainResult { + domain: step.domain, + ..MigrationDomainResult::default() + }); + } + } + report + .domain_results + .retain(|result| plan.steps.iter().any(|step| step.domain == result.domain)); + report.domain_results.sort_by_key(|result| { + plan.steps + .iter() + .position(|step| step.domain == result.domain) + .unwrap_or(usize::MAX) + }); +} + +fn transition( + layout: &MigrationLayout, + report: &mut MigrationRunReport, + sequence: &mut u64, + status: MigrationRunStatus, + phase: MigrationPhase, + domain: Option, + domain_state: Option, + code: &str, +) -> LegacyMigrationResult<()> { + report.status = status; + persist_report(layout, report)?; + journal(layout, report, sequence, phase, domain, domain_state, code) +} + +fn journal( + layout: &MigrationLayout, + report: &MigrationRunReport, + sequence: &mut u64, + phase: MigrationPhase, + domain: Option, + domain_state: Option, + code: &str, +) -> LegacyMigrationResult<()> { + *sequence = sequence.saturating_add(1); + layout.append_journal(&MigrationJournalEvent { + format_version: CURRENT_MIGRATION_FORMAT_VERSION, + sequence: *sequence, + recorded_at_ms: now_ms(), + run_id: report.run_id.clone(), + status: report.status, + phase, + domain, + domain_state, + code: code.to_string(), + }) +} + +fn record_domain_failure( + layout: &MigrationLayout, + report: &mut MigrationRunReport, + sequence: &mut u64, + result_index: usize, + domain: MigrationDomainId, + phase: MigrationPhase, +) -> LegacyMigrationResult<()> { + report.status = MigrationRunStatus::FailedRecoverable; + report.domain_results[result_index].state = MigrationDomainState::Failed; + report.diagnostics.push(MigrationDiagnostic { + code: "domain_failed_recoverable".to_string(), + severity: FindingSeverity::Blocking, + domain: Some(domain), + message: "A migration domain failed and can be retried".to_string(), + action: Some("Retry from Data Migrator after resolving the reported condition".to_string()), + ..MigrationDiagnostic::default() + }); + persist_report(layout, report)?; + journal( + layout, + report, + sequence, + phase, + Some(domain), + Some(MigrationDomainState::Failed), + "domain_failed_recoverable", + ) +} + +fn record_cancelled( + layout: &MigrationLayout, + report: &mut MigrationRunReport, + sequence: &mut u64, +) -> LegacyMigrationResult<()> { + transition( + layout, + report, + sequence, + MigrationRunStatus::Cancelled, + MigrationPhase::Stage, + None, + None, + "migration_cancelled", + ) +} + +fn persist_report( + layout: &MigrationLayout, + report: &MigrationRunReport, +) -> LegacyMigrationResult<()> { + atomic_write_json(&layout.report_path(), report) +} + +fn journal_sequence(layout: &MigrationLayout) -> LegacyMigrationResult { + let bytes = match std::fs::read(layout.journal_path()) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(LegacyMigrationError::io(layout.journal_path(), error)), + }; + Ok(bytes.iter().filter(|byte| **byte == b'\n').count() as u64) +} + +fn emit_progress( + progress: &mut impl FnMut(MigrationProgressEvent), + plan: &MigrationPlan, + step: &MigrationPlanStep, + phase: MigrationPhase, + processed: usize, + safe_to_cancel: bool, + code: &str, +) { + progress(MigrationProgressEvent { + run_id: plan.run_id.clone(), + domain: if phase == MigrationPhase::Finalize { + None + } else { + Some(step.domain) + }, + phase, + processed: processed as u64, + total: plan.steps.len() as u64, + safe_to_cancel, + code: code.to_string(), + }); +} + +fn inject(injector: &dyn CrashInjector, point: CrashPoint) -> LegacyMigrationResult<()> { + if injector.should_crash(point) { + Err(LegacyMigrationError::InjectedCrash(point)) + } else { + Ok(()) + } +} + +fn domain_error(domain: MigrationDomainId, error: LegacyMigrationError) -> LegacyMigrationError { + match error { + LegacyMigrationError::Domain { .. } => error, + error => LegacyMigrationError::Domain { + domain, + message: error.to_string(), + }, + } +} + +fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() +} diff --git a/src/crates/services/legacy-migration/src/error.rs b/src/crates/services/legacy-migration/src/error.rs new file mode 100644 index 0000000000..99a6063048 --- /dev/null +++ b/src/crates/services/legacy-migration/src/error.rs @@ -0,0 +1,76 @@ +use openbitfun_product_domains::legacy_migration::MigrationDomainId; +use std::path::PathBuf; + +pub type LegacyMigrationResult = Result; + +#[derive(Debug, thiserror::Error)] +pub enum LegacyMigrationError { + #[error("legacy migration path is unavailable: {0}")] + PathUnavailable(String), + #[error("legacy source and target resolve to the same path: {0}")] + SourceEqualsTarget(PathBuf), + #[error("legacy source format is unsupported: {0}")] + UnsupportedSource(String), + #[error("legacy migration request is invalid: {0}")] + InvalidRequest(String), + #[error("legacy migration plan is invalid: {0}")] + InvalidPlan(String), + #[error("legacy migration path escaped its declared root: {0}")] + PathEscape(PathBuf), + #[error("legacy migration refused a symbolic link or reparse point: {0}")] + LinkedPath(PathBuf), + #[error("legacy migration resource limit exceeded: {0}")] + ResourceLimit(String), + #[error("legacy migration is already running")] + LockUnavailable, + #[error("legacy migration was cancelled at a safe boundary")] + Cancelled, + #[error("legacy migration crash injection at {0:?}")] + InjectedCrash(crate::CrashPoint), + #[error("legacy migration domain {domain:?} failed: {message}")] + Domain { + domain: MigrationDomainId, + message: String, + }, + #[error("legacy migration I/O failed for {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("legacy migration JSON failed for {path}: {source}")] + Json { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("legacy migration SQLite failed for {path}: {source}")] + Sqlite { + path: PathBuf, + #[source] + source: rusqlite::Error, + }, +} + +impl LegacyMigrationError { + pub(crate) fn io(path: impl Into, source: std::io::Error) -> Self { + Self::Io { + path: path.into(), + source, + } + } + + pub(crate) fn json(path: impl Into, source: serde_json::Error) -> Self { + Self::Json { + path: path.into(), + source, + } + } + + pub(crate) fn sqlite(path: impl Into, source: rusqlite::Error) -> Self { + Self::Sqlite { + path: path.into(), + source, + } + } +} diff --git a/src/crates/services/legacy-migration/src/lib.rs b/src/crates/services/legacy-migration/src/lib.rs new file mode 100644 index 0000000000..d6804b799f --- /dev/null +++ b/src/crates/services/legacy-migration/src/lib.rs @@ -0,0 +1,18 @@ +//! Offline, source-read-only import primitives for legacy BitFun data. + +mod engine; +mod error; +mod paths; +mod probe; +mod sqlite; +mod storage; + +pub use engine::{ + compute_plan_hash, CancellationToken, CrashInjector, CrashPoint, DomainContext, DomainScan, + LegacyDomainAdapter, MigrationEngine, NoCrashInjection, +}; +pub use error::{LegacyMigrationError, LegacyMigrationResult}; +pub use paths::{MigrationRoots, LEGACY_PRODUCT_ID}; +pub use probe::{probe_legacy_source, ProbeLimits}; +pub use sqlite::{snapshot_sqlite_read_only, validate_sqlite}; +pub use storage::{atomic_write_json, MigrationLayout, MigrationLock}; diff --git a/src/crates/services/legacy-migration/src/paths.rs b/src/crates/services/legacy-migration/src/paths.rs new file mode 100644 index 0000000000..a180a875ac --- /dev/null +++ b/src/crates/services/legacy-migration/src/paths.rs @@ -0,0 +1,121 @@ +use crate::{LegacyMigrationError, LegacyMigrationResult}; +use openbitfun_services_core::product_identity::{data_namespace, hidden_data_directory}; +use std::env; +use std::path::{Path, PathBuf}; + +pub const LEGACY_PRODUCT_ID: &str = "bitfun"; +const LEGACY_HIDDEN_DATA_DIRECTORY: &str = ".bitfun"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MigrationRoots { + pub legacy_user_root: PathBuf, + pub legacy_home_root: PathBuf, + pub legacy_skills_root: PathBuf, + pub legacy_ssh_root: PathBuf, + pub target_user_root: PathBuf, + pub target_home_root: PathBuf, + pub target_skills_root: PathBuf, + pub target_ssh_root: PathBuf, +} + +impl MigrationRoots { + pub fn resolve_current_user() -> LegacyMigrationResult { + let config_root = dirs::config_dir().ok_or_else(|| { + LegacyMigrationError::PathUnavailable("platform config directory".to_string()) + })?; + let home = dirs::home_dir().ok_or_else(|| { + LegacyMigrationError::PathUnavailable("current user home directory".to_string()) + })?; + let data_root = dirs::data_dir().ok_or_else(|| { + LegacyMigrationError::PathUnavailable("platform data directory".to_string()) + })?; + let local_data_root = dirs::data_local_dir().ok_or_else(|| { + LegacyMigrationError::PathUnavailable("platform local data directory".to_string()) + })?; + + let legacy_user_root = env_path("BITFUN_USER_ROOT") + .or_else(|| env_path("BITFUN_E2E_USER_ROOT")) + .unwrap_or_else(|| config_root.join(LEGACY_PRODUCT_ID)); + let legacy_home_root = env_path("BITFUN_HOME") + .or_else(|| env_path("BITFUN_E2E_HOME")) + .unwrap_or_else(|| home.join(LEGACY_HIDDEN_DATA_DIRECTORY)); + let target_user_root = env_path("OPENBITFUN_USER_ROOT") + .or_else(|| env_path("OPENBITFUN_E2E_USER_ROOT")) + .unwrap_or_else(|| config_root.join(data_namespace())); + let target_home_root = env_path("OPENBITFUN_HOME") + .or_else(|| env_path("OPENBITFUN_E2E_HOME")) + .unwrap_or_else(|| home.join(hidden_data_directory())); + + let legacy_skills_root = platform_skills_root(&data_root, &local_data_root, "BitFun"); + let target_skills_root = + platform_skills_root(&data_root, &local_data_root, data_namespace()); + let legacy_ssh_root = local_data_root.join("BitFun").join("ssh"); + let target_ssh_root = local_data_root.join("OpenBitFun").join("ssh"); + + let roots = Self { + legacy_user_root, + legacy_home_root, + legacy_skills_root, + legacy_ssh_root, + target_user_root, + target_home_root, + target_skills_root, + target_ssh_root, + }; + roots.validate_distinct()?; + Ok(roots) + } + + pub fn migration_root(&self) -> PathBuf { + self.target_user_root + .join("data") + .join("migrations") + .join("bitfun-to-openbitfun") + } + + pub fn validate_distinct(&self) -> LegacyMigrationResult<()> { + for (source, target) in [ + (&self.legacy_user_root, &self.target_user_root), + (&self.legacy_home_root, &self.target_home_root), + (&self.legacy_skills_root, &self.target_skills_root), + (&self.legacy_ssh_root, &self.target_ssh_root), + ] { + if paths_equivalent(source, target) { + return Err(LegacyMigrationError::SourceEqualsTarget(source.clone())); + } + } + Ok(()) + } +} + +fn env_path(name: &str) -> Option { + env::var_os(name) + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) +} + +fn platform_skills_root(data_root: &Path, local_data_root: &Path, namespace: &str) -> PathBuf { + if cfg!(target_os = "windows") { + data_root.join(namespace).join("skills") + } else if cfg!(target_os = "macos") { + dirs::home_dir() + .unwrap_or_else(|| data_root.to_path_buf()) + .join("Library") + .join("Application Support") + .join(namespace) + .join("skills") + } else { + local_data_root.join(namespace).join("skills") + } +} + +fn paths_equivalent(left: &Path, right: &Path) -> bool { + let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf()); + let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf()); + if cfg!(windows) { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) + } else { + left == right + } +} diff --git a/src/crates/services/legacy-migration/src/probe.rs b/src/crates/services/legacy-migration/src/probe.rs new file mode 100644 index 0000000000..5562a58384 --- /dev/null +++ b/src/crates/services/legacy-migration/src/probe.rs @@ -0,0 +1,304 @@ +use crate::{LegacyMigrationError, LegacyMigrationResult, MigrationRoots, LEGACY_PRODUCT_ID}; +use openbitfun_product_domains::legacy_migration::{ + FindingSeverity, LegacyRootDescriptor, LegacyRootKind, LegacySourceDescriptor, + MigrationDiagnostic, +}; +use semver::{Version, VersionReq}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +const SUPPORTED_VERSION_REQ: &str = ">=0.2.0,<1.0.0"; + +#[derive(Debug, Clone, Copy)] +pub struct ProbeLimits { + pub max_entries: usize, + pub max_config_bytes: u64, +} + +impl Default for ProbeLimits { + fn default() -> Self { + Self { + max_entries: 4096, + max_config_bytes: 1024 * 1024, + } + } +} + +pub fn probe_legacy_source( + roots: &MigrationRoots, + limits: ProbeLimits, +) -> LegacyMigrationResult> { + roots.validate_distinct()?; + let version = read_source_version(&roots.legacy_user_root, limits.max_config_bytes)?; + let markers = supported_markers(roots)?; + if markers.iter().all(|marker| !marker.present) { + return Ok(None); + } + + let supported = version + .as_deref() + .and_then(|value| Version::parse(value).ok()) + .is_some_and(|value| { + VersionReq::parse(SUPPORTED_VERSION_REQ) + .expect("static version requirement is valid") + .matches(&value) + }); + + let mut hasher = Sha256::new(); + hasher.update(LEGACY_PRODUCT_ID.as_bytes()); + hasher.update(version.as_deref().unwrap_or("unknown").as_bytes()); + let mut approximate_bytes = 0u64; + let mut entries = 0usize; + for root in [ + &roots.legacy_user_root, + &roots.legacy_home_root, + &roots.legacy_skills_root, + &roots.legacy_ssh_root, + ] { + hash_path_fact(&mut hasher, root, root)?; + let summary = bounded_tree_summary(root, limits.max_entries.saturating_sub(entries))?; + approximate_bytes = approximate_bytes.saturating_add(summary.bytes); + entries = entries.saturating_add(summary.entries); + } + for marker in &markers { + hasher.update(marker.name.as_bytes()); + hasher.update([u8::from(marker.present)]); + } + let fingerprint = format!("sha256:{}", hex::encode(hasher.finalize())); + let source_id = format!("bitfun-{}", &fingerprint[7..23]); + let already_migrated = source_was_migrated(roots, &fingerprint); + let mut diagnostics = Vec::new(); + if version.is_none() { + diagnostics.push(MigrationDiagnostic { + code: "source_version_missing".to_string(), + severity: FindingSeverity::Blocking, + message: "Legacy BitFun version could not be identified".to_string(), + action: Some( + "Keep the source unchanged and use a supported BitFun profile".to_string(), + ), + ..MigrationDiagnostic::default() + }); + } else if !supported { + diagnostics.push(MigrationDiagnostic { + code: "source_version_unsupported".to_string(), + severity: FindingSeverity::Blocking, + message: format!("Legacy BitFun version is outside {SUPPORTED_VERSION_REQ}"), + action: Some("Use a migrator that supports this source version".to_string()), + ..MigrationDiagnostic::default() + }); + } + if entries >= limits.max_entries { + diagnostics.push(MigrationDiagnostic { + code: "probe_entry_limit_reached".to_string(), + severity: FindingSeverity::Warning, + message: "Legacy source is larger than the lightweight probe budget".to_string(), + action: Some("Run the full read-only scan in Data Migrator".to_string()), + ..MigrationDiagnostic::default() + }); + } + + Ok(Some(LegacySourceDescriptor { + source_id, + source_fingerprint: fingerprint, + product_id: LEGACY_PRODUCT_ID.to_string(), + product_version: version.unwrap_or_else(|| "unknown".to_string()), + platform: std::env::consts::OS.to_string(), + roots: vec![ + root_descriptor(LegacyRootKind::ProductData, &roots.legacy_user_root), + root_descriptor(LegacyRootKind::ProductHome, &roots.legacy_home_root), + root_descriptor(LegacyRootKind::RemoteSsh, &roots.legacy_ssh_root), + ], + readable: true, + supported, + approximate_bytes, + already_migrated, + diagnostics, + })) +} + +#[derive(Debug)] +struct MarkerFact { + name: &'static str, + present: bool, +} + +fn supported_markers(roots: &MigrationRoots) -> LegacyMigrationResult> { + Ok(vec![ + marker("settings", roots.legacy_user_root.join("config/app.json")), + marker("agents", roots.legacy_user_root.join("agents")), + MarkerFact { + name: "skills", + present: directory_has_user_content(&roots.legacy_skills_root, &[".system"])?, + }, + marker("miniapps", roots.legacy_user_root.join("data/miniapps")), + marker( + "workspaces", + roots.legacy_user_root.join("data/workspace_data.json"), + ), + marker("sessions", roots.legacy_home_root.join("projects")), + marker( + "coordination", + roots + .legacy_user_root + .join("data/agent-runtime/coordination.sqlite"), + ), + marker( + "structured_memory", + roots.legacy_user_root.join("data/memories/memories.sqlite"), + ), + marker("file_memory", roots.legacy_home_root.join("memories")), + marker( + "remote_connect", + roots + .legacy_home_root + .join("remote_connect_persistence.json"), + ), + marker( + "remote_ssh", + roots.legacy_ssh_root.join("ssh_connections.json"), + ), + ]) +} + +fn marker(name: &'static str, path: PathBuf) -> MarkerFact { + MarkerFact { + name, + present: path.exists(), + } +} + +fn directory_has_user_content(path: &Path, excluded_names: &[&str]) -> LegacyMigrationResult { + let entries = match fs::read_dir(path) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(LegacyMigrationError::io(path, error)), + }; + for entry in entries { + let entry = entry.map_err(|error| LegacyMigrationError::io(path, error))?; + if !excluded_names.iter().any(|name| { + entry + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case(name) + }) { + return Ok(true); + } + } + Ok(false) +} + +fn read_source_version(root: &Path, max_bytes: u64) -> LegacyMigrationResult> { + let path = root.join("config/app.json"); + let metadata = match fs::metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(LegacyMigrationError::io(&path, error)), + }; + if metadata.len() > max_bytes { + return Err(LegacyMigrationError::ResourceLimit( + "legacy app configuration exceeds the probe size limit".to_string(), + )); + } + let bytes = fs::read(&path).map_err(|error| LegacyMigrationError::io(&path, error))?; + let value: Value = + serde_json::from_slice(&bytes).map_err(|error| LegacyMigrationError::json(&path, error))?; + Ok(value + .get("version") + .and_then(Value::as_str) + .map(ToOwned::to_owned)) +} + +fn root_descriptor(kind: LegacyRootKind, path: &Path) -> LegacyRootDescriptor { + LegacyRootDescriptor { + kind, + display_path: path.to_string_lossy().to_string(), + } +} + +#[derive(Debug, Default)] +struct TreeSummary { + entries: usize, + bytes: u64, +} + +fn bounded_tree_summary(root: &Path, max_entries: usize) -> LegacyMigrationResult { + if max_entries == 0 || !root.exists() { + return Ok(TreeSummary::default()); + } + let mut summary = TreeSummary::default(); + let mut pending = vec![root.to_path_buf()]; + while let Some(path) = pending.pop() { + let metadata = + fs::symlink_metadata(&path).map_err(|error| LegacyMigrationError::io(&path, error))?; + if metadata.file_type().is_symlink() { + continue; + } + if metadata.is_file() { + summary.bytes = summary.bytes.saturating_add(metadata.len()); + summary.entries += 1; + } else if metadata.is_dir() { + for entry in + fs::read_dir(&path).map_err(|error| LegacyMigrationError::io(&path, error))? + { + let entry = entry.map_err(|error| LegacyMigrationError::io(&path, error))?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if should_skip_probe_name(&name) { + continue; + } + pending.push(entry.path()); + if summary.entries + pending.len() >= max_entries { + return Ok(summary); + } + } + } + } + Ok(summary) +} + +fn should_skip_probe_name(name: &str) -> bool { + matches!(name, "cache" | "logs" | "cli-logs" | "temp" | "runtimes") + || name.eq_ignore_ascii_case(".system") + || name.starts_with("ipc-v") + || matches!(name, "ownership" | "request-traces") +} + +fn hash_path_fact(hasher: &mut Sha256, root: &Path, path: &Path) -> LegacyMigrationResult<()> { + hasher.update(path.to_string_lossy().as_bytes()); + match fs::symlink_metadata(path) { + Ok(metadata) => { + hasher.update(metadata.len().to_le_bytes()); + let modified_ms = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(); + hasher.update(modified_ms.to_le_bytes()); + hasher.update( + path.strip_prefix(root) + .unwrap_or(path) + .as_os_str() + .as_encoded_bytes(), + ); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => hasher.update(b"missing"), + Err(error) => return Err(LegacyMigrationError::io(path, error)), + } + Ok(()) +} + +fn source_was_migrated(roots: &MigrationRoots, fingerprint: &str) -> bool { + let path = roots.migration_root().join("onboarding.json"); + let Ok(bytes) = fs::read(path) else { + return false; + }; + serde_json::from_slice::(&bytes) + .ok() + .and_then(|value| value.get("sourceFingerprint").cloned()) + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .is_some_and(|value| value == fingerprint) +} diff --git a/src/crates/services/legacy-migration/src/sqlite.rs b/src/crates/services/legacy-migration/src/sqlite.rs new file mode 100644 index 0000000000..cf8ddee0da --- /dev/null +++ b/src/crates/services/legacy-migration/src/sqlite.rs @@ -0,0 +1,61 @@ +use crate::{LegacyMigrationError, LegacyMigrationResult}; +use rusqlite::backup::Backup; +use rusqlite::{Connection, OpenFlags}; +use std::path::Path; +use std::time::Duration; + +pub fn snapshot_sqlite_read_only(source: &Path, destination: &Path) -> LegacyMigrationResult<()> { + if destination.exists() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "SQLite snapshot destination already exists: {}", + destination.display() + ))); + } + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent).map_err(|error| LegacyMigrationError::io(parent, error))?; + } + let source_connection = Connection::open_with_flags( + source, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| LegacyMigrationError::sqlite(source, error))?; + source_connection + .pragma_update(None, "query_only", true) + .map_err(|error| LegacyMigrationError::sqlite(source, error))?; + validate_connection(&source_connection, source)?; + + let mut destination_connection = Connection::open(destination) + .map_err(|error| LegacyMigrationError::sqlite(destination, error))?; + let backup = Backup::new(&source_connection, &mut destination_connection) + .map_err(|error| LegacyMigrationError::sqlite(source, error))?; + backup + .run_to_completion(128, Duration::from_millis(5), None) + .map_err(|error| LegacyMigrationError::sqlite(destination, error))?; + drop(backup); + destination_connection + .execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;") + .map_err(|error| LegacyMigrationError::sqlite(destination, error))?; + validate_connection(&destination_connection, destination) +} + +pub fn validate_sqlite(path: &Path) -> LegacyMigrationResult<()> { + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| LegacyMigrationError::sqlite(path, error))?; + validate_connection(&connection, path) +} + +fn validate_connection(connection: &Connection, path: &Path) -> LegacyMigrationResult<()> { + let result: String = connection + .query_row("PRAGMA integrity_check(1)", [], |row| row.get(0)) + .map_err(|error| LegacyMigrationError::sqlite(path, error))?; + if result != "ok" { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "SQLite integrity check failed for {}", + path.display() + ))); + } + Ok(()) +} diff --git a/src/crates/services/legacy-migration/src/storage.rs b/src/crates/services/legacy-migration/src/storage.rs new file mode 100644 index 0000000000..6d1e7eed82 --- /dev/null +++ b/src/crates/services/legacy-migration/src/storage.rs @@ -0,0 +1,191 @@ +use crate::{LegacyMigrationError, LegacyMigrationResult, MigrationRoots}; +use fs2::FileExt; +use serde::Serialize; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub struct MigrationLayout { + root: PathBuf, + run_id: String, +} + +impl MigrationLayout { + pub fn new(roots: &MigrationRoots, run_id: impl Into) -> Self { + Self { + root: roots.migration_root(), + run_id: run_id.into(), + } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn run_root(&self) -> PathBuf { + self.root.join("runs").join(&self.run_id) + } + + pub fn request_path(&self) -> PathBuf { + self.run_root().join("request.json") + } + + pub fn plan_path(&self) -> PathBuf { + self.run_root().join("plan.json") + } + + pub fn journal_path(&self) -> PathBuf { + self.run_root().join("journal.jsonl") + } + + pub fn report_path(&self) -> PathBuf { + self.run_root().join("report.json") + } + + pub fn stage_root(&self) -> PathBuf { + self.run_root().join("stage") + } + + pub fn backup_root(&self) -> PathBuf { + self.run_root().join("backup") + } + + pub fn lock_path(&self) -> PathBuf { + self.root.join("lock") + } + + pub fn initialize(&self) -> LegacyMigrationResult<()> { + for path in [self.run_root(), self.stage_root(), self.backup_root()] { + fs::create_dir_all(&path).map_err(|error| LegacyMigrationError::io(&path, error))?; + } + Ok(()) + } + + pub fn append_journal(&self, event: &T) -> LegacyMigrationResult<()> { + let path = self.journal_path(); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| LegacyMigrationError::io(&path, error))?; + serde_json::to_writer(&mut file, event) + .map_err(|error| LegacyMigrationError::json(&path, error))?; + file.write_all(b"\n") + .map_err(|error| LegacyMigrationError::io(&path, error))?; + file.sync_data() + .map_err(|error| LegacyMigrationError::io(&path, error)) + } + + pub fn read_json( + &self, + path: &Path, + ) -> LegacyMigrationResult> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(LegacyMigrationError::io(path, error)), + }; + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|error| LegacyMigrationError::json(path, error)) + } +} + +pub struct MigrationLock { + file: File, +} + +impl MigrationLock { + pub fn acquire(layout: &MigrationLayout) -> LegacyMigrationResult { + fs::create_dir_all(layout.root()) + .map_err(|error| LegacyMigrationError::io(layout.root(), error))?; + let path = layout.lock_path(); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(&path) + .map_err(|error| LegacyMigrationError::io(&path, error))?; + file.try_lock_exclusive() + .map_err(|_| LegacyMigrationError::LockUnavailable)?; + Ok(Self { file }) + } +} + +impl Drop for MigrationLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.file); + } +} + +pub fn atomic_write_json(path: &Path, value: &T) -> LegacyMigrationResult<()> { + let bytes = serde_json::to_vec_pretty(value) + .map_err(|error| LegacyMigrationError::json(path, error))?; + atomic_write(path, &bytes) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> LegacyMigrationResult<()> { + let parent = path.parent().ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!("path has no parent: {}", path.display())) + })?; + fs::create_dir_all(parent).map_err(|error| LegacyMigrationError::io(parent, error))?; + let temp = parent.join(format!( + ".{}.{}.tmp", + path.file_name().unwrap_or_default().to_string_lossy(), + uuid::Uuid::new_v4() + )); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp) + .map_err(|error| LegacyMigrationError::io(&temp, error))?; + file.write_all(bytes) + .map_err(|error| LegacyMigrationError::io(&temp, error))?; + file.write_all(b"\n") + .map_err(|error| LegacyMigrationError::io(&temp, error))?; + file.sync_all() + .map_err(|error| LegacyMigrationError::io(&temp, error))?; + drop(file); + if let Err(error) = replace_file(&temp, path) { + let _ = fs::remove_file(&temp); + return Err(error); + } + if let Ok(directory) = File::open(parent) { + let _ = directory.sync_all(); + } + Ok(()) +} + +#[cfg(not(windows))] +fn replace_file(source: &Path, target: &Path) -> LegacyMigrationResult<()> { + fs::rename(source, target).map_err(|error| LegacyMigrationError::io(target, error)) +} + +#[cfg(windows)] +fn replace_file(source: &Path, target: &Path) -> LegacyMigrationResult<()> { + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source_wide = source + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let target_wide = target + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + unsafe { + MoveFileExW( + PCWSTR(source_wide.as_ptr()), + PCWSTR(target_wide.as_ptr()), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + .map_err(|error| LegacyMigrationError::io(target, std::io::Error::other(error.to_string()))) + } +} diff --git a/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs b/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs new file mode 100644 index 0000000000..85d91412e6 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs @@ -0,0 +1,321 @@ +use openbitfun_legacy_migration::{ + atomic_write_json, probe_legacy_source, snapshot_sqlite_read_only, CancellationToken, + CrashInjector, CrashPoint, DomainContext, DomainScan, LegacyDomainAdapter, + LegacyMigrationError, LegacyMigrationResult, MigrationEngine, MigrationLayout, MigrationLock, + MigrationRoots, NoCrashInjection, ProbeLimits, +}; +use openbitfun_product_domains::legacy_migration::{ + MigrationDomainId, MigrationDomainResult, MigrationDomainState, MigrationGroupId, + MigrationRunStatus, MigrationSelection, ScanFinding, +}; +use rusqlite::Connection; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct CallCounts { + scan: usize, + stage: usize, + validate_stage: usize, + commit: usize, + validate_commit: usize, +} + +struct FakeAdapter { + domain: MigrationDomainId, + calls: Arc>>, +} + +impl LegacyDomainAdapter for FakeAdapter { + fn domain(&self) -> MigrationDomainId { + self.domain + } + + fn scan(&self, _roots: &MigrationRoots) -> LegacyMigrationResult { + self.update(|counts| counts.scan += 1); + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain, + code: "fake_source_supported".to_string(), + entity_count: 1, + logical_bytes: 16, + source_schema: Some("fake.v1".to_string()), + migratable: true, + ..ScanFinding::default() + }, + conflicts: Vec::new(), + target_schema: Some("fake.current".to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + self.update(|counts| counts.stage += 1); + atomic_write_json( + &stage_path(context, self.domain), + &serde_json::json!({"domain": format!("{:?}", self.domain), "entities": ["stable-id"]}), + )?; + Ok(MigrationDomainResult { + domain: self.domain, + state: MigrationDomainState::Staged, + imported: 1, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + self.update(|counts| counts.validate_stage += 1); + require_file(&stage_path(context, self.domain)) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + self.update(|counts| counts.commit += 1); + let staged = fs::read(stage_path(context, self.domain)).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("missing fake stage: {error}")) + })?; + let value: serde_json::Value = serde_json::from_slice(&staged).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("invalid fake stage: {error}")) + })?; + // Rewriting the same owner record is intentional: recovery can repeat a + // commit whose target write succeeded before its journal marker did. + atomic_write_json(&target_path(context, self.domain), &value) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + self.update(|counts| counts.validate_commit += 1); + require_file(&target_path(context, self.domain)) + } +} + +impl FakeAdapter { + fn update(&self, update: impl FnOnce(&mut CallCounts)) { + let mut calls = self + .calls + .lock() + .expect("fake call state should not poison"); + update(calls.entry(self.domain).or_default()); + } +} + +struct CrashOnce { + point: CrashPoint, + fired: AtomicBool, +} + +impl CrashInjector for CrashOnce { + fn should_crash(&self, point: CrashPoint) -> bool { + point == self.point && !self.fired.swap(true, Ordering::AcqRel) + } +} + +#[test] +fn engine_recovers_after_commit_before_journal_and_deduplicates_repeated_runs() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + seed_supported_source(&roots); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .expect("probe should succeed") + .expect("source should be present"); + let calls = Arc::new(Mutex::new(BTreeMap::new())); + let engine = fake_engine(roots.clone(), Arc::clone(&calls)); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }; + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .expect("plan should succeed"); + let crash = CrashOnce { + point: CrashPoint::AfterCommit(MigrationDomainId::Settings), + fired: AtomicBool::new(false), + }; + + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &crash), + Err(LegacyMigrationError::InjectedCrash( + CrashPoint::AfterCommit(MigrationDomainId::Settings) + )) + )); + let recovered = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .expect("retry should recover"); + assert_eq!(recovered.status, MigrationRunStatus::Completed); + assert!(recovered + .domain_results + .iter() + .all(|result| result.state == MigrationDomainState::Verified)); + + let before_repeat = calls.lock().expect("calls should not poison").clone(); + let repeated = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .expect("completed run should be readable"); + assert_eq!(repeated, recovered); + assert_eq!( + *calls.lock().expect("calls should not poison"), + before_repeat, + "a completed plan must not execute owner writes again" + ); + assert_eq!( + before_repeat + .get(&MigrationDomainId::Settings) + .expect("settings calls") + .commit, + 2, + "the owner idempotently retries an ambiguous commit" + ); + assert!(roots.legacy_user_root.join("config/app.json").exists()); +} + +#[test] +fn sqlite_wal_snapshot_contains_committed_rows_without_changing_source_files() { + let temp = test_tempdir(); + let source = temp.path().join("source.sqlite"); + let destination = temp.path().join("stage/snapshot.sqlite"); + let connection = Connection::open(&source).expect("source SQLite should open"); + connection + .execute_batch( + "PRAGMA journal_mode=WAL;\ + PRAGMA wal_autocheckpoint=0;\ + CREATE TABLE items(id INTEGER PRIMARY KEY, value TEXT NOT NULL);\ + INSERT INTO items(value) VALUES ('first'), ('second');", + ) + .expect("WAL fixture should be created"); + let source_files = sqlite_family(&source); + assert!(source_files + .iter() + .any(|path| path.to_string_lossy().ends_with("-wal"))); + let before = source_files + .iter() + .filter(|path| !path.to_string_lossy().ends_with("-shm")) + .map(|path| (path.clone(), sha256_file(path))) + .collect::>(); + + snapshot_sqlite_read_only(&source, &destination).expect("snapshot should succeed"); + + let snapshot = Connection::open(&destination).expect("snapshot should open"); + let count: i64 = snapshot + .query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0)) + .expect("snapshot rows should be readable"); + assert_eq!(count, 2); + drop(snapshot); + for (path, expected_hash) in before { + assert_eq!( + sha256_file(&path), + expected_hash, + "source changed: {}", + path.display() + ); + } + assert!(!destination.with_extension("sqlite-shm").exists()); + assert!(!destination.with_extension("sqlite-wal").exists()); +} + +#[test] +fn lock_contention_and_source_target_aliases_fail_closed() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + let layout = MigrationLayout::new(&roots, "lock-test"); + let _first = MigrationLock::acquire(&layout).expect("first lock should succeed"); + assert!(matches!( + MigrationLock::acquire(&layout), + Err(LegacyMigrationError::LockUnavailable) + )); + + let mut aliased = roots; + aliased.target_user_root = aliased.legacy_user_root.clone(); + assert!(matches!( + aliased.validate_distinct(), + Err(LegacyMigrationError::SourceEqualsTarget(_)) + )); +} + +fn fake_engine( + roots: MigrationRoots, + calls: Arc>>, +) -> MigrationEngine { + let adapters = [ + MigrationDomainId::Settings, + MigrationDomainId::Credentials, + MigrationDomainId::CrossReferenceRepair, + ] + .into_iter() + .map(|domain| { + Box::new(FakeAdapter { + domain, + calls: Arc::clone(&calls), + }) as Box + }); + MigrationEngine::new(roots, adapters).expect("fake engine should be valid") +} + +fn fixture_roots(root: &Path) -> MigrationRoots { + MigrationRoots { + legacy_user_root: root.join("legacy/user"), + legacy_home_root: root.join("legacy/home"), + legacy_skills_root: root.join("legacy/skills"), + legacy_ssh_root: root.join("legacy/ssh"), + target_user_root: root.join("target/user"), + target_home_root: root.join("target/home"), + target_skills_root: root.join("target/skills"), + target_ssh_root: root.join("target/ssh"), + } +} + +fn seed_supported_source(roots: &MigrationRoots) { + let path = roots.legacy_user_root.join("config/app.json"); + fs::create_dir_all(path.parent().expect("configuration should have a parent")) + .expect("legacy config directory should be created"); + fs::write(path, r#"{"version":"0.2.19"}"#).expect("legacy configuration should be written"); +} + +fn stage_path(context: &DomainContext<'_>, domain: MigrationDomainId) -> PathBuf { + context.layout.stage_root().join(format!("{domain:?}.json")) +} + +fn target_path(context: &DomainContext<'_>, domain: MigrationDomainId) -> PathBuf { + context + .roots + .target_user_root + .join("data/fake-owner") + .join(format!("{domain:?}.json")) +} + +fn require_file(path: &Path) -> LegacyMigrationResult<()> { + if path.is_file() { + Ok(()) + } else { + Err(LegacyMigrationError::InvalidRequest(format!( + "expected fake owner file: {}", + path.display() + ))) + } +} + +fn sqlite_family(database: &Path) -> Vec { + let mut paths = vec![database.to_path_buf()]; + for suffix in ["-wal", "-shm"] { + let path = PathBuf::from(format!("{}{suffix}", database.display())); + if path.exists() { + paths.push(path); + } + } + paths +} + +fn sha256_file(path: &Path) -> String { + let bytes = fs::read(path).expect("fixture file should remain readable"); + hex::encode(Sha256::digest(bytes)) +} + +fn test_tempdir() -> tempfile::TempDir { + match std::env::var_os("OPENBITFUN_TEST_TMPDIR") { + Some(root) => tempfile::Builder::new() + .prefix("legacy-migration-") + .tempdir_in(root) + .expect("temporary directory should be created in the requested root"), + None => tempfile::tempdir().expect("temporary directory should be created"), + } +} From 56ce252ba62b4b5921d68a96a0d2d7cbc5349abb Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 7 Sep 2026 21:46:37 +0800 Subject: [PATCH 3/7] feat(migration): Implement legacy data imports Connect the offline engine to the persistence owners of legacy user content so each domain can convert and validate its own records. - Import application settings and credentials with target conflict handling and product-specific configuration conversion. - Add import adapters for user skills, agents, and MiniApps. - Migrate workspace and session records together with coordination data, and repair the historical coordination schema v2 layout. - Expose offline persistence helpers for session and workspace data without starting the normal Agent Runtime. - Add memory, SSH, and Remote Connect import adapters with explicit handling of credentials and existing target records. - Extend fixtures and focused contracts to exercise owner imports, persisted metadata, and recovery after interrupted migration. Keep persistence behavior with its existing owners and limit the migration delivery profile to the dependencies it actually needs. --- Cargo.lock | 1 + scripts/check-core-boundaries.test.mjs | 3 + .../cargo-dependency-boundaries.mjs | 2 + .../core-boundaries/rules/feature-rules.mjs | 57 +- .../rules/source/forbidden-rules.mjs | 2 +- .../rules/source/required-rules.mjs | 12 +- scripts/core-boundaries/self-test.mjs | 11 +- scripts/product-identity-audit.mjs | 1 + scripts/product-identity-audit.test.mjs | 7 + src/crates/assembly/core/Cargo.toml | 21 + .../coordination/coordination_store.rs | 198 +- .../assembly/core/src/agentic/memories/db.rs | 282 +-- .../core/src/agentic/memories/workspace.rs | 9 +- .../core/src/agentic/persistence/manager.rs | 18 +- .../legacy_migration/agent_coordination.rs | 2122 ++++++++++++++++ .../core/src/legacy_migration/common.rs | 124 + .../core/src/legacy_migration/extensions.rs | 1719 +++++++++++++ .../core/src/legacy_migration/memory.rs | 2124 +++++++++++++++++ .../assembly/core/src/legacy_migration/mod.rs | 121 + .../src/legacy_migration/remote_connect.rs | 1515 ++++++++++++ .../core/src/legacy_migration/remote_ssh.rs | 1185 +++++++++ .../core/src/legacy_migration/settings.rs | 808 +++++++ .../legacy_migration/workspace_sessions.rs | 1595 +++++++++++++ src/crates/assembly/core/src/lib.rs | 2 + .../src/service/coordination_persistence.rs | 177 ++ src/crates/assembly/core/src/service/mod.rs | 7 + .../src/service/session_projection_format.rs | 121 + .../src/service/session_projection_store.rs | 11 +- .../core/src/service/workspace/manager.rs | 170 +- .../core/src/service/workspace/mod.rs | 13 + .../core/src/service/workspace/persistence.rs | 230 ++ .../core/src/service/workspace/service.rs | 236 +- .../core/src/service/workspace/types.rs | 175 ++ .../src/legacy_migration/mod.rs | 6 + .../tests/legacy_migration_contracts.rs | 19 +- src/crates/execution/agent-runtime/Cargo.toml | 8 + src/crates/execution/agent-runtime/src/lib.rs | 6 +- .../execution/agent-runtime/src/skills/mod.rs | 9 + .../services/legacy-migration/src/engine.rs | 63 +- .../services/legacy-migration/src/lib.rs | 2 +- .../services/legacy-migration/src/storage.rs | 10 +- .../tests/fixtures/v0.2.19/README.md | 4 + .../fixtures/v0.2.19/fixture-manifest.json | 5 +- .../v0.2.19/home/device_identity.json | 6 +- .../sessions/session-1/metadata.json | 19 + .../session-1/request-traces/trace.json | 1 + .../sessions/session-1/turns/turn-0000.json | 19 + .../sessions/session-child-1/metadata.json | 27 + .../session-child-1/turns/turn-0000.json | 19 + .../sessions/session-1/metadata.json | 7 - .../session-1/request-traces/trace.json | 1 - .../sessions/session-1/turns/turn-1.json | 1 - .../home/remote_connect_persistence.json | 27 +- .../home/runtime-events/session-1.jsonl | 2 +- .../tests/fixtures/v0.2.19/ssh/known_hosts | 10 +- .../v0.2.19/ssh/remote_workspace.json | 4 +- .../fixtures/v0.2.19/ssh/ssh_connections.json | 7 +- .../data/agent-runtime/coordination.sql | 1 + .../user-root/data/memories/memories.sql | 61 +- .../user-root/data/workspace_data.json | 16 +- .../tests/migration_engine_contracts.rs | 189 +- src/crates/services/services-core/AGENTS.md | 7 +- src/crates/services/services-core/Cargo.toml | 1 + src/crates/services/services-core/src/lib.rs | 2 + .../services-core/src/memory_store.rs | 456 ++++ .../services/services-core/src/session/mod.rs | 4 + .../src/session/offline_import.rs | 230 ++ .../services-core/src/session/types.rs | 16 + .../services/services-integrations/AGENTS.md | 5 + .../services/services-integrations/Cargo.toml | 22 + .../services/services-integrations/src/lib.rs | 5 +- .../services-integrations/src/miniapp/mod.rs | 4 + .../src/miniapp/storage.rs | 85 + .../src/remote_connect.rs | 2 +- .../src/remote_connect/bot/mod.rs | 16 +- .../src/remote_connect/device.rs | 31 +- .../src/remote_connect/session_store.rs | 327 +-- .../src/remote_connect/sync_state.rs | 52 +- .../src/remote_persistence.rs | 1239 ++++++++++ 79 files changed, 14899 insertions(+), 1233 deletions(-) create mode 100644 src/crates/assembly/core/src/legacy_migration/agent_coordination.rs create mode 100644 src/crates/assembly/core/src/legacy_migration/common.rs create mode 100644 src/crates/assembly/core/src/legacy_migration/extensions.rs create mode 100644 src/crates/assembly/core/src/legacy_migration/memory.rs create mode 100644 src/crates/assembly/core/src/legacy_migration/mod.rs create mode 100644 src/crates/assembly/core/src/legacy_migration/remote_connect.rs create mode 100644 src/crates/assembly/core/src/legacy_migration/remote_ssh.rs create mode 100644 src/crates/assembly/core/src/legacy_migration/settings.rs create mode 100644 src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs create mode 100644 src/crates/assembly/core/src/service/coordination_persistence.rs create mode 100644 src/crates/assembly/core/src/service/session_projection_format.rs create mode 100644 src/crates/assembly/core/src/service/workspace/persistence.rs create mode 100644 src/crates/assembly/core/src/service/workspace/types.rs create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/metadata.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/request-traces/trace.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/turns/turn-0000.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-child-1/metadata.json create mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-child-1/turns/turn-0000.json delete mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/metadata.json delete mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/request-traces/trace.json delete mode 100644 src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/turns/turn-1.json create mode 100644 src/crates/services/services-core/src/memory_store.rs create mode 100644 src/crates/services/services-core/src/session/offline_import.rs create mode 100644 src/crates/services/services-integrations/src/remote_persistence.rs diff --git a/Cargo.lock b/Cargo.lock index bac4d5a719..6d8ce28663 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5901,6 +5901,7 @@ dependencies = [ "openbitfun-dsh-adapter", "openbitfun-events", "openbitfun-external-sources", + "openbitfun-legacy-migration", "openbitfun-opencode-adapter", "openbitfun-opencode-plugin-host", "openbitfun-plugin-runtime-client", diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 6d3ba14bc6..fd91e23ea3 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -194,6 +194,7 @@ test('Agent Runtime leaf capabilities have one managed feature and source contra assert.deepEqual(Object.keys(rule.featureProfiles).sort(), [ 'agent-runtime', 'default', + 'definition-contracts', 'native-hook-runtime', 'native-hook-settings', ]); @@ -4154,6 +4155,7 @@ test('Core Tokio capabilities cannot hide behind an unreviewed owner feature', ( ], features: { 'agent-runtime': ['tokio/io-util', 'tokio/macros', 'tokio/rt', 'tokio/time'], + 'legacy-migration': ['tokio/rt'], 'mcp-runtime': ['agent-runtime', 'tokio/rt-multi-thread'], 'browser-control': ['tokio/net', 'tokio/rt', 'tokio/time'], sneaky: ['agent-runtime', 'browser-control'], @@ -4175,6 +4177,7 @@ test('reviewed Tokio aggregates cannot declare runtime capabilities directly', ( dependencies: [{ name: 'tokio', kind: null, optional: false, features: ['fs', 'sync'] }], features: { 'agent-runtime': ['tokio/io-util', 'tokio/macros', 'tokio/rt', 'tokio/time'], + 'legacy-migration': ['tokio/rt'], 'mcp-runtime': ['agent-runtime', 'tokio/rt-multi-thread'], 'browser-control': ['tokio/net', 'tokio/rt', 'tokio/time'], 'product-full': ['agent-runtime', 'tokio/net'], diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 41509024c0..57e9fdebb0 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -141,6 +141,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-storage', ['fs', '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']], @@ -173,6 +174,7 @@ const SERVICES_INTEGRATIONS_TOKIO_AGGREGATES = new Set(['product-full']); const SERVICES_CORE_TOKIO_AGGREGATES = new Set(['session-git', 'token-usage-statistics']); const CORE_TOKIO_FEATURES = new Map([ ['agent-runtime', ['io-util', 'macros', 'rt', 'time']], + ['legacy-migration', ['rt']], ['mcp-runtime', ['io-util', 'macros', 'rt', 'rt-multi-thread', 'time']], ['browser-control', ['net', 'rt', 'time']], ]); diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index e85d8ece48..69b71fe238 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -70,7 +70,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'workspace-instructions', ], }, - { depName: 'rusqlite', ownerFeatures: ['permission', 'session-search'] }, + { depName: 'rusqlite', ownerFeatures: ['memory-store', 'permission', 'session-search'] }, { depName: 'rustls', ownerFeatures: ['tls-provider'] }, { depName: 'serde_yaml', ownerFeatures: ['markdown', 'workspace-instructions'] }, { depName: 'similar', ownerFeatures: ['diff', 'local-storage'] }, @@ -128,19 +128,19 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'async-trait', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, { depName: 'openbitfun-agent-stream', ownerFeatures: ['agent-runtime'] }, { depName: 'openbitfun-agent-tools', ownerFeatures: ['agent-runtime'] }, - { depName: 'openbitfun-core-types', ownerFeatures: ['agent-runtime'] }, + { depName: 'openbitfun-core-types', ownerFeatures: ['agent-runtime', 'definition-contracts'] }, { depName: 'openbitfun-events', ownerFeatures: ['agent-runtime'] }, { depName: 'openbitfun-runtime-ports', ownerFeatures: ['agent-runtime'] }, { depName: 'openbitfun-runtime-services', ownerFeatures: ['agent-runtime'] }, { depName: 'dashmap', ownerFeatures: ['agent-runtime'] }, { depName: 'hex', ownerFeatures: ['agent-runtime'] }, { depName: 'log', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, - { depName: 'regex', ownerFeatures: ['agent-runtime', 'native-hook-settings'] }, - { depName: 'serde', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, + { depName: 'regex', ownerFeatures: ['agent-runtime', 'definition-contracts', 'native-hook-settings'] }, + { depName: 'serde', ownerFeatures: ['agent-runtime', 'definition-contracts', 'native-hook-runtime'] }, { depName: 'serde_json', ownerFeatures: ['agent-runtime', 'native-hook-runtime', 'native-hook-settings'] }, - { depName: 'serde_yaml', ownerFeatures: ['agent-runtime'] }, + { depName: 'serde_yaml', ownerFeatures: ['agent-runtime', 'definition-contracts'] }, { depName: 'sha2', ownerFeatures: ['agent-runtime'] }, - { depName: 'thiserror', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, + { depName: 'thiserror', ownerFeatures: ['agent-runtime', 'definition-contracts', 'native-hook-runtime'] }, { depName: 'tokio', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] }, { depName: 'tokio-util', ownerFeatures: ['agent-runtime'] }, { depName: 'uuid', ownerFeatures: ['agent-runtime'] }, @@ -157,13 +157,14 @@ export const optionalDependencyFeatureOwnerRules = [ depName: 'openbitfun-ai-adapters', ownerFeatures: ['ai-adapter-runtime', 'subscription-auth'], }, - { depName: 'openbitfun-agent-runtime', ownerFeatures: ['agent-runtime'] }, + { depName: 'openbitfun-agent-runtime', ownerFeatures: ['agent-runtime', 'legacy-migration'] }, { depName: 'openbitfun-agent-workflows', ownerFeatures: ['deep-research'] }, { depName: 'openbitfun-agent-stream', ownerFeatures: ['agent-runtime'] }, { depName: 'openbitfun-agent-tools', ownerFeatures: ['agent-runtime', 'local-storage', 'mcp-runtime'] }, { depName: 'openbitfun-claude-code-adapter', ownerFeatures: ['external-sources'] }, { depName: 'openbitfun-codex-adapter', ownerFeatures: ['external-sources'] }, { depName: 'openbitfun-external-sources', ownerFeatures: ['external-sources'] }, + { depName: 'openbitfun-legacy-migration', ownerFeatures: ['legacy-migration'] }, { depName: 'openbitfun-opencode-adapter', ownerFeatures: ['external-sources'] }, { depName: 'openbitfun-dsh-adapter', ownerFeatures: ['external-sources'] }, { depName: 'openbitfun-plugin-runtime-client', ownerFeatures: ['plugin-runtime'] }, @@ -174,6 +175,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'agent-runtime', 'canvas-runtime', 'function-agents', + 'legacy-migration', 'plugin-source', 'product-search', 'tools-miniapp', @@ -191,6 +193,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'external-sources', 'file-watch', 'function-agents', + 'legacy-migration', 'git', 'mcp-runtime', 'model-catalog', @@ -236,7 +239,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'md5', ownerFeatures: ['agent-runtime'] }, { depName: 'reqwest', ownerFeatures: ['mcp-runtime', 'tools-miniapp'] }, { depName: 'regex', ownerFeatures: ['agent-runtime'] }, - { depName: 'rusqlite', ownerFeatures: ['agent-runtime'] }, + { depName: 'rusqlite', ownerFeatures: ['agent-runtime', 'legacy-migration'] }, { depName: 'semver', ownerFeatures: ['tools-miniapp'] }, { depName: 'serde_yaml', ownerFeatures: ['workspace-runtime'] }, { depName: 'similar', ownerFeatures: ['agent-runtime'] }, @@ -258,15 +261,15 @@ export const optionalDependencyFeatureOwnerRules = [ reviewedAggregateFeatures: ['speech-realtime'], dependencies: [ { depName: 'aes', ownerFeatures: ['remote-connect'] }, - { depName: 'aes-gcm', ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete'] }, - { depName: 'anyhow', ownerFeatures: ['browser-control', 'deep-research', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] }, + { depName: 'aes-gcm', ownerFeatures: ['mcp', 'remote-connect', 'remote-persistence', 'remote-ssh-concrete'] }, + { depName: 'anyhow', ownerFeatures: ['browser-control', 'deep-research', 'mcp', 'remote-connect', 'remote-persistence', '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'], }, { depName: 'base64', - ownerFeatures: ['mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete', 'speech'], + ownerFeatures: ['mcp', 'miniapp-runtime', 'remote-connect', 'remote-persistence', 'remote-ssh-concrete', 'speech'], }, { depName: 'openbitfun-agent-runtime', ownerFeatures: ['hook-import'] }, { depName: 'openbitfun-agent-workflows', ownerFeatures: ['deep-research'] }, @@ -274,7 +277,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-market', 'miniapp-runtime', 'miniapp-storage', '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', @@ -289,6 +292,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'models-dev', 'process-tree', 'remote-connect', + 'remote-persistence', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', @@ -306,7 +310,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'futures-util', ownerFeatures: ['speech', 'web-tools'] }, { depName: 'git2', ownerFeatures: ['git'] }, { depName: 'hex', ownerFeatures: ['hook-import', 'mcp', 'miniapp-market', 'plugin-source', 'remote-connect'] }, - { depName: 'hostname', ownerFeatures: ['remote-connect'] }, + { depName: 'hostname', ownerFeatures: ['remote-connect', 'remote-persistence'] }, { depName: 'image', ownerFeatures: ['miniapp-market', 'remote-connect'] }, { depName: 'local-ip-address', ownerFeatures: ['remote-connect'] }, { depName: 'libc', ownerFeatures: ['plugin-source'] }, @@ -315,7 +319,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'notify', ownerFeatures: ['file-watch'] }, { depName: 'oxc', ownerFeatures: ['canvas-runtime'] }, { depName: 'qrcode', ownerFeatures: ['remote-connect'] }, - { depName: 'rand', ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete'] }, + { depName: 'rand', ownerFeatures: ['mcp', 'remote-connect', 'remote-persistence', 'remote-ssh-concrete'] }, // remote-ssh-concrete: one-click relay deploy fetches the signed release // checksum over HTTPS and verifies it on this device, because the target // server has no minisign and no trust root of its own. @@ -327,7 +331,7 @@ 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-market', 'models-dev', 'plugin-source', 'remote-connect', 'remote-persistence', 'remote-ssh', 'review-platform', 'speech'] }, { depName: 'sherpa-onnx', ownerFeatures: ['speech'] }, { depName: 'shellexpand', ownerFeatures: ['remote-ssh-concrete'] }, { depName: 'sse-stream', ownerFeatures: ['mcp'] }, @@ -338,9 +342,9 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'tokio-tungstenite', ownerFeatures: ['remote-connect', 'speech-realtime'] }, { depName: 'tokio-util', ownerFeatures: ['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: 'uuid', ownerFeatures: ['canvas-runtime', 'hook-import', 'miniapp-runtime', 'miniapp-storage', 'plugin-source', 'remote-connect', 'remote-ssh-concrete', 'speech'] }, { depName: 'which', ownerFeatures: ['miniapp-runtime', 'remote-connect', 'script-tool-runtime', 'workspace-search'] }, - { depName: 'windows', ownerFeatures: ['models-dev', 'plugin-source', 'review-platform'] }, + { depName: 'windows', ownerFeatures: ['models-dev', 'plugin-source', 'remote-connect', 'remote-persistence', 'remote-ssh-concrete', 'review-platform'] }, { depName: 'x25519-dalek', ownerFeatures: ['remote-connect'] }, ], }, @@ -585,6 +589,13 @@ export const capabilityContractDependencyRules = [ manifestPath: 'src/crates/execution/agent-runtime/Cargo.toml', featureProfiles: { default: [], + 'definition-contracts': [ + 'dep:openbitfun-core-types', + 'dep:regex', + 'dep:serde', + 'dep:serde_yaml', + 'dep:thiserror', + ], 'native-hook-settings': ['dep:regex', 'dep:serde_json'], 'native-hook-runtime': [ 'native-hook-settings', @@ -601,6 +612,7 @@ export const capabilityContractDependencyRules = [ 'tokio/time', ], 'agent-runtime': [ + 'definition-contracts', 'native-hook-runtime', 'dep:async-trait', 'dep:openbitfun-agent-stream', @@ -643,8 +655,9 @@ export const capabilityContractDependencyRules = [ [capabilityEdge([], { optional: true })], [ capabilityForwarder('agent-runtime', 'agent-runtime'), + capabilityForwarder('legacy-migration', 'definition-contracts'), ], - ['agent-runtime'], + ['agent-runtime', 'legacy-migration'], ['external-sources', 'mcp-runtime', 'opencode-plugin-host', 'plugin-runtime', 'product-search', 'product-full', 'remote-connect', 'tools-mcp'], )], ['openbitfun-desktop', capabilityConsumer([ @@ -869,6 +882,7 @@ export const coreClosedFeatureProfileRules = [ 'dep:tool-runtime', // Complete ExecCommand constraint syntax facts live in the tool owner. 'tool-runtime/shell-analysis', + 'openbitfun-services-core/memory-store', 'openbitfun-services-core/permission', 'openbitfun-services-core/runtime-ownership', 'openbitfun-services-core/session-git', @@ -1478,6 +1492,13 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'services-core local-storage must own durable JSON, session, usage, and cleanup primitives', }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'memory-store', + requiredFeatureRefs: ['dep:rusqlite'], + exact: true, + reason: 'services-core memory-store must own only the durable Memory SQLite format', + }, { manifestPath: 'src/crates/services/services-core/Cargo.toml', featureName: 'token-usage-statistics', diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 3c62363b7b..c005cb7764 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -3,7 +3,7 @@ import { agentRuntimeRootPublicModules } from './public-api-rules.mjs'; const agentRuntimeRootUnexpectedLine = new RegExp( - `^(?!(?:[ \\t]*|[ \\t]*\\/\\/!.*|[ \\t]*#\\[cfg\\(feature = "(?:agent-runtime|deep-research|native-hook-settings)"\\)\\][ \\t]*|[ \\t]*pub mod (?:${agentRuntimeRootPublicModules.join('|')});[ \\t]*)\\r?$).+$`, + `^(?!(?:[ \\t]*|[ \\t]*\\/\\/!.*|[ \\t]*#\\[cfg\\(feature = "(?:agent-runtime|deep-research|native-hook-settings)"\\)\\][ \\t]*|[ \\t]*#\\[cfg\\(any\\(feature = "agent-runtime", feature = "definition-contracts"\\)\\)\\][ \\t]*|[ \\t]*pub mod (?:${agentRuntimeRootPublicModules.join('|')});[ \\t]*)\\r?$).+$`, 'm', ); diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 18d641a4e8..dfa82f0157 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -2,6 +2,8 @@ import { agentRuntimeRootPublicModules } from './public-api-rules.mjs'; +const agentRuntimeDefinitionContractModules = new Set(['custom_agent', 'prompt', 'skills']); + export const requiredContentRules = [ { path: 'src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts', @@ -5156,8 +5158,14 @@ export const requiredContentRules = [ ...agentRuntimeRootPublicModules .filter((moduleName) => moduleName !== 'native_hooks') .map((moduleName) => ({ - regex: new RegExp(`#\\[cfg\\(feature = "agent-runtime"\\)\\]\\r?\\npub mod ${moduleName};`), - message: `${moduleName} must stay behind the full agent-runtime owner`, + regex: new RegExp( + agentRuntimeDefinitionContractModules.has(moduleName) + ? `#\\[cfg\\(any\\(feature = "agent-runtime", feature = "definition-contracts"\\)\\)\\]\\r?\\npub mod ${moduleName};` + : `#\\[cfg\\(feature = "agent-runtime"\\)\\]\\r?\\npub mod ${moduleName};`, + ), + message: agentRuntimeDefinitionContractModules.has(moduleName) + ? `${moduleName} must stay behind the full runtime or definition-contracts owner` + : `${moduleName} must stay behind the full agent-runtime owner`, })), ], }, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 544866c589..19fb144680 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -434,6 +434,7 @@ export function runManifestParserSelfTest({ ], ], [servicesCoreManifest, 'product-identity', ['dep:openbitfun-core-types']], + [servicesCoreManifest, 'memory-store', ['dep:rusqlite']], [ servicesCoreManifest, 'local-storage', @@ -1160,7 +1161,7 @@ export function runManifestParserSelfTest({ 'regex', ['diagnostics', 'filesystem', 'local-storage', 'markdown', 'workspace-instructions'], ], - ['rusqlite', ['permission']], + ['rusqlite', ['memory-store', 'permission']], ['serde_yaml', ['markdown', 'workspace-instructions']], ['similar', ['diff', 'local-storage']], [ @@ -1259,6 +1260,14 @@ export function runManifestParserSelfTest({ throw new Error(`services-integrations plugin-source must own optional dependency ${dep}`); } } + for (const dep of ['aes-gcm', 'anyhow', 'base64', 'hostname', 'openbitfun-services-core', 'rand', 'sha2', 'windows']) { + const owner = servicesOptionalOwnerRule?.dependencies.find( + (dependency) => dependency.depName === dep, + ); + if (!owner?.ownerFeatures.includes('remote-persistence')) { + throw new Error(`services-integrations remote-persistence must own optional dependency ${dep}`); + } + } for (const dep of ['openbitfun-product-domains', 'image']) { const owner = servicesOptionalOwnerRule?.dependencies.find( (dependency) => dependency.depName === dep, diff --git a/scripts/product-identity-audit.mjs b/scripts/product-identity-audit.mjs index 89e5f33e49..b24e5aeb32 100644 --- a/scripts/product-identity-audit.mjs +++ b/scripts/product-identity-audit.mjs @@ -32,6 +32,7 @@ const retiredIdentityDataBoundaryFiles = new Set([ 'src/apps/mobile/harmonyos/entry/src/main/resources/base/profile/backup_config.json', ]); const retiredIdentityDataBoundaryPrefixes = Object.freeze([ + 'src/crates/assembly/core/src/legacy_migration/', 'src/crates/services/legacy-migration/', ]); const noncanonicalIdentityDataBoundaryFiles = new Set([ diff --git a/scripts/product-identity-audit.test.mjs b/scripts/product-identity-audit.test.mjs index 53c29c69f3..3f8fbf6bbd 100644 --- a/scripts/product-identity-audit.test.mjs +++ b/scripts/product-identity-audit.test.mjs @@ -124,6 +124,13 @@ test('limits retired identity data to the one-time production migration boundary ), [], ); + assert.deepEqual( + violationsFor( + `const SOURCE_PRODUCT: &str = "${retiredLowerName}";`, + 'src/crates/assembly/core/src/legacy_migration/source.rs', + ), + [], + ); assert.equal( violationsFor( `const SOURCE_PRODUCT: &str = "${retiredLowerName}";`, diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 2f7cbdd23a..9ed1273665 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -85,6 +85,9 @@ openbitfun-services-core = { path = "../../services/services-core", features = [ # Integration service owner crate openbitfun-services-integrations = { path = "../../services/services-integrations", optional = true } +# Offline legacy-data migration engine. Product-owned adapters are assembled in Core. +openbitfun-legacy-migration = { path = "../../services/legacy-migration", optional = true } + # Product domain owner crate openbitfun-product-domains = { path = "../../contracts/product-domains", optional = true } @@ -187,6 +190,7 @@ product-full = [ "product-capabilities", "runtime-services", "tool-packs", + "legacy-migration", ] # Core compatibility facade and concrete product Agent Runtime assembly. This # owns the existing agent/session/tool lifecycle, not app presentation or @@ -224,6 +228,7 @@ agent-runtime = [ "dep:tool-runtime", "tool-runtime/shell-analysis", "openbitfun-services-core/permission", + "openbitfun-services-core/memory-store", "openbitfun-services-core/runtime-ownership", "openbitfun-services-core/workspace-text-runtime", "openbitfun-services-core/session-git", @@ -409,6 +414,22 @@ canvas-runtime = [ "openbitfun-services-integrations/canvas-runtime", ] runtime-services = ["dep:openbitfun-runtime-services"] +legacy-migration = [ + "dep:openbitfun-agent-runtime", + "dep:openbitfun-legacy-migration", + "dep:openbitfun-product-domains", + "dep:openbitfun-services-integrations", + "dep:rusqlite", + "openbitfun-agent-runtime/definition-contracts", + "openbitfun-product-domains/legacy-migration", + "openbitfun-product-domains/miniapp", + "openbitfun-services-core/local-storage", + "openbitfun-services-core/memory-store", + "openbitfun-services-core/workspace-identity", + "openbitfun-services-integrations/miniapp-storage", + "openbitfun-services-integrations/remote-persistence", + "tokio/rt", +] announcement = ["openbitfun-services-integrations/announcement"] file-watch = ["openbitfun-services-integrations/file-watch"] git = ["openbitfun-services-integrations/git"] diff --git a/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs b/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs index a3ee6f4622..33e6283957 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs @@ -1,3 +1,6 @@ +use crate::service::coordination_persistence::{ + initialize_coordination_schema, validate_coordination_agent_id, +}; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; use std::path::PathBuf; @@ -7,7 +10,6 @@ use tokio::sync::OnceCell; use tokio::task; use uuid::Uuid; -const SCHEMA_VERSION: i64 = 2; const SWARM_MAX_NODES: i64 = 128; const SWARM_MAX_DEPTH: i64 = 4; @@ -1092,23 +1094,7 @@ fn get_or_create_agent( } pub(crate) fn validate_agent_id(agent_id: &str) -> OpenBitFunResult<()> { - let valid = !agent_id.is_empty() - && agent_id.len() <= 32 - && agent_id - .bytes() - .enumerate() - .all(|(index, byte)| match byte { - b'a'..=b'z' => true, - b'0'..=b'9' | b'_' | b'-' => index > 0, - _ => false, - }); - if valid { - Ok(()) - } else { - Err(OpenBitFunError::tool( - "agent_id must match [a-z][a-z0-9_-]{0,31}".to_string(), - )) - } + validate_coordination_agent_id(agent_id) } fn open_connection(db_path: PathBuf) -> OpenBitFunResult>> { @@ -1138,23 +1124,67 @@ PRAGMA synchronous = NORMAL; "#, ) .map_err(db_error)?; - initialize_schema(&connection)?; + initialize_coordination_schema(&connection)?; Ok(Arc::new(Mutex::new(connection))) } -fn initialize_schema(connection: &Connection) -> OpenBitFunResult<()> { - let version = connection - .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) - .map_err(db_error)?; - if version > SCHEMA_VERSION { - return Err(OpenBitFunError::service(format!( - "Agent coordination database schema {version} is newer than supported schema {SCHEMA_VERSION}" - ))); +fn db_error(error: rusqlite::Error) -> OpenBitFunError { + OpenBitFunError::io(format!("Agent coordination database error: {error}")) +} + +fn unix_time_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::coordination_persistence::{ + coordination_table_has_column, initialize_coordination_schema, + }; + + fn test_tempdir() -> tempfile::TempDir { + if let Some(root) = std::env::var_os("OPENBITFUN_TEST_TMPDIR") { + let root = PathBuf::from(root); + std::fs::create_dir_all(&root).expect("create coordination test temp root"); + return tempfile::Builder::new() + .prefix("coordination-store-") + .tempdir_in(root) + .expect("coordination store temp directory"); + } + tempfile::tempdir().expect("coordination store temp directory") } - if version == SCHEMA_VERSION { - return Ok(()); + + fn test_store() -> (tempfile::TempDir, CoordinationStore) { + let root = test_tempdir(); + let store = CoordinationStore::new(root.path().join("coordination.sqlite")); + (root, store) + } + + fn registration( + parent_session_id: &str, + child_session_id: &str, + parent_dialog_turn_id: &str, + requested_agent_id: Option<&str>, + ) -> BackgroundTaskRegistration { + BackgroundTaskRegistration { + parent_session_id: parent_session_id.to_string(), + requested_agent_id: requested_agent_id.map(str::to_string), + child_session_id: child_session_id.to_string(), + parent_dialog_turn_id: parent_dialog_turn_id.to_string(), + parent_tool_call_id: format!("tool-{parent_dialog_turn_id}"), + child_dialog_turn_id: format!("turn-{child_session_id}-{parent_dialog_turn_id}"), + } } - if version == 0 { + + #[tokio::test] + async fn schema_v2_repairs_missing_delivery_columns_idempotently() { + let root = test_tempdir(); + let db_path = root.path().join("coordination.sqlite"); + let connection = Connection::open(&db_path).expect("open historical database"); connection .execute_batch( r#" @@ -1163,19 +1193,15 @@ CREATE TABLE coordination_sessions ( next_auto_agent_seq INTEGER NOT NULL DEFAULT 1, updated_at_ms INTEGER NOT NULL ); - CREATE TABLE agents ( agent_pk INTEGER PRIMARY KEY AUTOINCREMENT, parent_session_id TEXT NOT NULL, agent_id TEXT NOT NULL, child_session_id TEXT, next_bg_seq INTEGER NOT NULL DEFAULT 1, - state TEXT NOT NULL CHECK (state IN ('active', 'historical')), - created_at_ms INTEGER NOT NULL, - UNIQUE(parent_session_id, agent_id), - UNIQUE(parent_session_id, child_session_id) + state TEXT NOT NULL, + created_at_ms INTEGER NOT NULL ); - CREATE TABLE background_tasks ( task_pk INTEGER PRIMARY KEY AUTOINCREMENT, parent_session_id TEXT NOT NULL, @@ -1185,95 +1211,59 @@ CREATE TABLE background_tasks ( parent_dialog_turn_id TEXT NOT NULL, parent_tool_call_id TEXT NOT NULL, child_dialog_turn_id TEXT NOT NULL, - status TEXT NOT NULL CHECK ( - status IN ('running', 'completed', 'partial_timeout', 'failed', 'cancelled', 'interrupted') - ), + status TEXT NOT NULL, error_code TEXT, error_message TEXT, execution_owner_token TEXT NOT NULL, created_at_ms INTEGER NOT NULL, - terminal_at_ms INTEGER, - delivered_at_ms INTEGER, - delivered_parent_dialog_turn_id TEXT, - UNIQUE(parent_session_id, bg_task_id), - UNIQUE(agent_pk, bg_ordinal), - FOREIGN KEY(agent_pk) REFERENCES agents(agent_pk) ON DELETE CASCADE + terminal_at_ms INTEGER ); - -CREATE INDEX idx_background_tasks_wait - ON background_tasks(parent_session_id, delivered_at_ms, status, task_pk); -CREATE INDEX idx_background_tasks_parent_turn - ON background_tasks(parent_session_id, parent_dialog_turn_id); - -PRAGMA user_version = 1; - "#, - ) - .map_err(db_error)?; - } - if version < 2 { - connection - .execute_batch( - r#" CREATE TABLE swarm_trees ( root_session_id TEXT PRIMARY KEY, created_at_ms INTEGER NOT NULL ); - CREATE TABLE swarm_nodes ( session_id TEXT PRIMARY KEY, root_session_id TEXT NOT NULL, parent_session_id TEXT, agent_type TEXT NOT NULL, depth INTEGER NOT NULL, - created_at_ms INTEGER NOT NULL, - FOREIGN KEY(root_session_id) REFERENCES swarm_trees(root_session_id) ON DELETE CASCADE + created_at_ms INTEGER NOT NULL +); +INSERT INTO coordination_sessions VALUES ('parent', 2, 1); +INSERT INTO agents VALUES (1, 'parent', 'helper', 'child', 2, 'historical', 1); +INSERT INTO background_tasks VALUES ( + 1, 'parent', 1, 'helper_bg1', 1, 'parent-turn', 'tool-call', + 'child-turn', 'completed', NULL, NULL, 'historical-owner', 1, 2 ); - -CREATE INDEX idx_swarm_nodes_root ON swarm_nodes(root_session_id); -CREATE INDEX idx_swarm_nodes_parent ON swarm_nodes(parent_session_id); PRAGMA user_version = 2; "#, ) - .map_err(db_error)?; - } - Ok(()) -} - -fn db_error(error: rusqlite::Error) -> OpenBitFunError { - OpenBitFunError::io(format!("Agent coordination database error: {error}")) -} + .expect("seed historical schema v2"); + drop(connection); -fn unix_time_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_store() -> (tempfile::TempDir, CoordinationStore) { - let root = tempfile::tempdir().expect("coordination store temp directory"); - let store = CoordinationStore::new(root.path().join("coordination.sqlite")); - (root, store) - } + let store = CoordinationStore::new(db_path.clone()); + let candidates = store + .wait_candidates("parent", &[]) + .await + .expect("repaired database should support current reads"); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].bg_task_id, "helper_bg1"); + assert!(candidates[0].delivered_at_ms.is_none()); + drop(store); - fn registration( - parent_session_id: &str, - child_session_id: &str, - parent_dialog_turn_id: &str, - requested_agent_id: Option<&str>, - ) -> BackgroundTaskRegistration { - BackgroundTaskRegistration { - parent_session_id: parent_session_id.to_string(), - requested_agent_id: requested_agent_id.map(str::to_string), - child_session_id: child_session_id.to_string(), - parent_dialog_turn_id: parent_dialog_turn_id.to_string(), - parent_tool_call_id: format!("tool-{parent_dialog_turn_id}"), - child_dialog_turn_id: format!("turn-{child_session_id}-{parent_dialog_turn_id}"), - } + let connection = Connection::open(&db_path).expect("reopen repaired database"); + initialize_coordination_schema(&connection).expect("repeated repair should be idempotent"); + assert!( + coordination_table_has_column(&connection, "background_tasks", "delivered_at_ms") + .expect("inspect delivered_at_ms") + ); + assert!(coordination_table_has_column( + &connection, + "background_tasks", + "delivered_parent_dialog_turn_id" + ) + .expect("inspect delivered_parent_dialog_turn_id")); } #[tokio::test] diff --git a/src/crates/assembly/core/src/agentic/memories/db.rs b/src/crates/assembly/core/src/agentic/memories/db.rs index b6d5fa3cac..925b3183ac 100644 --- a/src/crates/assembly/core/src/agentic/memories/db.rs +++ b/src/crates/assembly/core/src/agentic/memories/db.rs @@ -1,6 +1,13 @@ use crate::agentic::core::message::MemoryCitation; use crate::infrastructure::PathManager; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; +use openbitfun_services_core::memory_store::{ + decode_memory_job, decode_memory_record, initialize_memory_schema, upsert_memory_record, + EXPECTED_JOBS_COLUMNS, EXPECTED_STAGE1_COLUMNS, +}; +pub use openbitfun_services_core::memory_store::{ + MemoryJobRecord as MemoryJobRow, MemoryRecord as MemoryRow, +}; use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -18,22 +25,6 @@ const JOB_STATUS_DONE: &str = "done"; const JOB_STATUS_ERROR: &str = "error"; const DEFAULT_RETRY_REMAINING: i64 = 3; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct MemoryRow { - pub session_id: String, - pub workspace_path: String, - pub rollout_path: String, - pub source_updated_at_unix_secs: i64, - pub raw_memory: String, - pub rollout_summary: String, - pub rollout_slug: Option, - pub generated_at_unix_secs: i64, - pub usage_count: i64, - pub last_usage_unix_secs: Option, - pub selected_for_phase2: i64, - pub selected_for_phase2_source_updated_at: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MemoryPhase2CandidateRow { pub session_id: String, @@ -56,39 +47,6 @@ pub struct MemoryPhase2SelectionRow { pub input_watermark: i64, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct MemoryJobRow { - pub kind: String, - pub job_key: String, - pub status: String, - pub worker_id: Option, - pub ownership_token: Option, - pub started_at_unix_secs: Option, - pub finished_at_unix_secs: Option, - pub lease_until_unix_secs: Option, - pub retry_at_unix_secs: Option, - pub retry_remaining: i64, - pub last_error: Option, - pub input_watermark: Option, - pub last_success_watermark: Option, -} - -impl MemoryJobRow { - pub fn success_cooldown_until_unix_secs(&self, cooldown_seconds: i64) -> Option { - if self.kind != JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL - || self.status != JOB_STATUS_DONE - || self.last_error.is_some() - || self.input_watermark.is_none() - || self.last_success_watermark != self.input_watermark - { - return None; - } - - self.finished_at_unix_secs - .map(|finished_at| finished_at.saturating_add(cooldown_seconds.max(0))) - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub enum MemoryPhase1ClaimOutcome { Claimed { ownership_token: String }, @@ -1219,106 +1177,9 @@ fn open_connection(path: &Path) -> OpenBitFunResult { } fn initialize_schema(conn: &Connection) -> OpenBitFunResult<()> { - recreate_table_if_shape_differs(conn, "stage1_outputs", EXPECTED_STAGE1_COLUMNS)?; - recreate_table_if_shape_differs(conn, "jobs", EXPECTED_JOBS_COLUMNS)?; - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS stage1_outputs ( - thread_id TEXT PRIMARY KEY NOT NULL, - workspace_path TEXT NOT NULL, - rollout_path TEXT NOT NULL, - source_updated_at INTEGER NOT NULL, - raw_memory TEXT NOT NULL, - rollout_summary TEXT NOT NULL, - rollout_slug TEXT, - generated_at INTEGER NOT NULL, - usage_count INTEGER, - last_usage INTEGER, - selected_for_phase2 INTEGER NOT NULL DEFAULT 0, - selected_for_phase2_source_updated_at INTEGER - ); - - CREATE INDEX IF NOT EXISTS idx_stage1_outputs_source_updated_at - ON stage1_outputs(source_updated_at DESC, thread_id DESC); - - CREATE TABLE IF NOT EXISTS jobs ( - kind TEXT NOT NULL, - job_key TEXT NOT NULL, - status TEXT NOT NULL, - worker_id TEXT, - ownership_token TEXT, - started_at INTEGER, - finished_at INTEGER, - lease_until INTEGER, - retry_at INTEGER, - retry_remaining INTEGER NOT NULL, - last_error TEXT, - input_watermark INTEGER, - last_success_watermark INTEGER, - PRIMARY KEY (kind, job_key) - ); - - CREATE INDEX IF NOT EXISTS idx_jobs_kind_status_retry_lease - ON jobs(kind, status, retry_at, lease_until); - "#, - ) - .map_err(|error| { - OpenBitFunError::io(format!("Failed to initialize memories schema: {}", error)) - })?; - Ok(()) -} - -const EXPECTED_STAGE1_COLUMNS: &[&str] = &[ - "thread_id", - "workspace_path", - "rollout_path", - "source_updated_at", - "raw_memory", - "rollout_summary", - "rollout_slug", - "generated_at", - "usage_count", - "last_usage", - "selected_for_phase2", - "selected_for_phase2_source_updated_at", -]; - -const EXPECTED_JOBS_COLUMNS: &[&str] = &[ - "kind", - "job_key", - "status", - "worker_id", - "ownership_token", - "started_at", - "finished_at", - "lease_until", - "retry_at", - "retry_remaining", - "last_error", - "input_watermark", - "last_success_watermark", -]; - -fn recreate_table_if_shape_differs( - conn: &Connection, - table_name: &str, - expected_columns: &[&str], -) -> OpenBitFunResult<()> { - let columns = table_columns(conn, table_name)?; - let expected_columns = expected_columns - .iter() - .map(|column| column.to_string()) - .collect::>(); - if !columns.is_empty() && columns != expected_columns { - conn.execute(&format!("DROP TABLE IF EXISTS {table_name}"), []) - .map_err(|error| { - OpenBitFunError::io(format!( - "Failed to drop incompatible memories table {}: {}", - table_name, error - )) - })?; - } - Ok(()) + initialize_memory_schema(conn).map_err(|error| { + OpenBitFunError::io(format!("Failed to initialize memories schema: {error}")) + }) } fn table_columns(conn: &Connection, table_name: &str) -> OpenBitFunResult> { @@ -1362,71 +1223,14 @@ fn clear_memory_state_in_conn(conn: &Connection) -> OpenBitFunResult<()> { Ok(()) } -const UPSERT_STAGE1_OUTPUT_SQL: &str = r#" - INSERT INTO stage1_outputs ( - thread_id, workspace_path, rollout_path, source_updated_at, raw_memory, - rollout_summary, rollout_slug, generated_at, usage_count, - last_usage, selected_for_phase2, selected_for_phase2_source_updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(thread_id) DO UPDATE SET - workspace_path = excluded.workspace_path, - rollout_path = excluded.rollout_path, - source_updated_at = excluded.source_updated_at, - raw_memory = excluded.raw_memory, - rollout_summary = excluded.rollout_summary, - rollout_slug = excluded.rollout_slug, - generated_at = excluded.generated_at, - usage_count = CASE - WHEN ? != 0 THEN excluded.usage_count - ELSE stage1_outputs.usage_count - END, - last_usage = CASE - WHEN ? != 0 THEN excluded.last_usage - ELSE stage1_outputs.last_usage - END, - selected_for_phase2 = CASE - WHEN ? != 0 THEN excluded.selected_for_phase2 - ELSE stage1_outputs.selected_for_phase2 - END, - selected_for_phase2_source_updated_at = CASE - WHEN ? != 0 THEN excluded.selected_for_phase2_source_updated_at - ELSE stage1_outputs.selected_for_phase2_source_updated_at - END - WHERE excluded.source_updated_at >= stage1_outputs.source_updated_at - "#; - fn upsert_stage1_output_conn( conn: &Connection, row: &MemoryRow, overwrite_usage_and_selection: bool, ) -> OpenBitFunResult<()> { - let overwrite = if overwrite_usage_and_selection { 1 } else { 0 }; - conn.execute( - UPSERT_STAGE1_OUTPUT_SQL, - params![ - &row.session_id, - &row.workspace_path, - &row.rollout_path, - row.source_updated_at_unix_secs, - &row.raw_memory, - &row.rollout_summary, - &row.rollout_slug, - row.generated_at_unix_secs, - row.usage_count, - row.last_usage_unix_secs, - row.selected_for_phase2, - row.selected_for_phase2_source_updated_at, - overwrite, - overwrite, - overwrite, - overwrite, - ], - ) - .map_err(|error| { - OpenBitFunError::io(format!("Failed to upsert memory stage1 output: {}", error)) - })?; - Ok(()) + upsert_memory_record(conn, row, overwrite_usage_and_selection).map_err(|error| { + OpenBitFunError::io(format!("Failed to upsert memory stage1 output: {error}")) + }) } fn upsert_stage1_output_tx( @@ -1434,32 +1238,9 @@ fn upsert_stage1_output_tx( row: &MemoryRow, overwrite_usage_and_selection: bool, ) -> OpenBitFunResult<()> { - let overwrite = if overwrite_usage_and_selection { 1 } else { 0 }; - tx.execute( - UPSERT_STAGE1_OUTPUT_SQL, - params![ - &row.session_id, - &row.workspace_path, - &row.rollout_path, - row.source_updated_at_unix_secs, - &row.raw_memory, - &row.rollout_summary, - &row.rollout_slug, - row.generated_at_unix_secs, - row.usage_count, - row.last_usage_unix_secs, - row.selected_for_phase2, - row.selected_for_phase2_source_updated_at, - overwrite, - overwrite, - overwrite, - overwrite, - ], - ) - .map_err(|error| { - OpenBitFunError::io(format!("Failed to upsert memory stage1 output: {}", error)) - })?; - Ok(()) + upsert_memory_record(tx, row, overwrite_usage_and_selection).map_err(|error| { + OpenBitFunError::io(format!("Failed to upsert memory stage1 output: {error}")) + }) } fn phase1_source_needs_update_in_conn( @@ -1723,38 +1504,11 @@ fn get_job_in_tx( } fn row_to_job(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(MemoryJobRow { - kind: row.get(0)?, - job_key: row.get(1)?, - status: row.get(2)?, - worker_id: row.get(3)?, - ownership_token: row.get(4)?, - started_at_unix_secs: row.get(5)?, - finished_at_unix_secs: row.get(6)?, - lease_until_unix_secs: row.get(7)?, - retry_at_unix_secs: row.get(8)?, - retry_remaining: row.get(9)?, - last_error: row.get(10)?, - input_watermark: row.get(11)?, - last_success_watermark: row.get(12)?, - }) + decode_memory_job(row) } fn row_to_memory(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(MemoryRow { - session_id: row.get(0)?, - workspace_path: row.get(1)?, - rollout_path: row.get(2)?, - source_updated_at_unix_secs: row.get(3)?, - raw_memory: row.get(4)?, - rollout_summary: row.get(5)?, - rollout_slug: row.get(6)?, - generated_at_unix_secs: row.get(7)?, - usage_count: row.get(8)?, - last_usage_unix_secs: row.get(9)?, - selected_for_phase2: row.get(10)?, - selected_for_phase2_source_updated_at: row.get(11)?, - }) + decode_memory_record(row) } fn row_to_phase2_candidate(row: &rusqlite::Row<'_>) -> rusqlite::Result { diff --git a/src/crates/assembly/core/src/agentic/memories/workspace.rs b/src/crates/assembly/core/src/agentic/memories/workspace.rs index cec61dae46..3dd8512d6c 100644 --- a/src/crates/assembly/core/src/agentic/memories/workspace.rs +++ b/src/crates/assembly/core/src/agentic/memories/workspace.rs @@ -2,6 +2,10 @@ use crate::agentic::memories::db::MemoryRow; use crate::infrastructure::get_path_manager_arc; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use chrono::{DateTime, Utc}; +pub use openbitfun_services_core::memory_store::{ + AD_HOC_EXTENSION_NAME, AD_HOC_NOTES_DIR_NAME, MEMORY_EXTENSIONS_DIR_NAME, MEMORY_FILE_NAME, + MEMORY_SUMMARY_FILE_NAME, +}; use openbitfun_services_core::session::MemoryWorkspaceGitError; pub use openbitfun_services_core::session::{ MemoryWorkspaceChange, MemoryWorkspaceChangeStatus, MemoryWorkspaceDiff, @@ -14,13 +18,8 @@ use uuid::Uuid; pub const MEMORY_ROOT_NAME: &str = "memories"; pub const RAW_MEMORIES_FILENAME: &str = "raw_memories.md"; -pub const MEMORY_FILE_NAME: &str = "MEMORY.md"; -pub const MEMORY_SUMMARY_FILE_NAME: &str = "memory_summary.md"; pub const PHASE2_WORKSPACE_DIFF_FILE_NAME: &str = "phase2_workspace_diff.md"; pub const ROLLOUT_SUMMARIES_DIR_NAME: &str = "rollout_summaries"; -pub const MEMORY_EXTENSIONS_DIR_NAME: &str = "extensions"; -pub const AD_HOC_EXTENSION_NAME: &str = "ad_hoc"; -pub const AD_HOC_NOTES_DIR_NAME: &str = "notes"; pub const AD_HOC_INSTRUCTIONS_FILE_NAME: &str = "instructions.md"; const AD_HOC_INSTRUCTIONS: &str = r#"# Ad-hoc notes diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index b9f535b1d9..319325c973 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -29,9 +29,9 @@ use crate::service::remote_ssh::workspace_state::{ }; use crate::service::session::{ DialogTurnData, SessionMetadata, SessionTranscriptExport, SessionTranscriptExportOptions, - SessionTurnCatalog, SessionTurnCatalogEntry, SessionTurnWindowResponse, TranscriptLineRange, - TurnRailCapsulePreview, TurnRailCapsuleSegment, SESSION_STORAGE_SCHEMA_VERSION, - SESSION_TURN_CATALOG_SCHEMA_VERSION, + SessionTurnCatalog, SessionTurnCatalogEntry, SessionTurnWindowResponse, StoredDialogTurnFile, + TranscriptLineRange, TurnRailCapsulePreview, TurnRailCapsuleSegment, + SESSION_STORAGE_SCHEMA_VERSION, SESSION_TURN_CATALOG_SCHEMA_VERSION, }; use crate::service::workspace_runtime::WorkspaceRuntimeService; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; @@ -160,13 +160,6 @@ fn current_unix_secs() -> i64 { .unwrap_or_default() } -#[derive(Debug, Clone, Serialize, Deserialize)] -struct StoredDialogTurnFile { - schema_version: u32, - #[serde(flatten)] - turn: DialogTurnData, -} - /// Legacy navigation repair reads only identity/outcome fields. In particular, /// do not flatten this DTO: serde flatten would materialize ignored messages /// and tool payloads while collecting the unknown fields. @@ -3535,10 +3528,7 @@ impl PersistenceManager { self.invalidate_session_search(workspace_path, &turn.session_id) .await; - let file = StoredDialogTurnFile { - schema_version: SESSION_STORAGE_SCHEMA_VERSION, - turn: turn.clone(), - }; + let file = StoredDialogTurnFile::new(turn.clone()); let write_started_at = Instant::now(); self.write_json_atomic( &self.turn_path(workspace_path, &turn.session_id, turn.turn_index), diff --git a/src/crates/assembly/core/src/legacy_migration/agent_coordination.rs b/src/crates/assembly/core/src/legacy_migration/agent_coordination.rs new file mode 100644 index 0000000000..2600f12a1e --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/agent_coordination.rs @@ -0,0 +1,2122 @@ +use super::common::{ + backup_domain_dir, io_error, read_bounded_json, read_optional_bounded_json, stage_domain_dir, + validate_regular_file, +}; +use super::workspace_sessions::{ + read_workspace_sessions_manifest, target_wins_session_ids, SessionImportAction, + WorkspaceSessionsManifest, +}; +use crate::service::coordination_persistence::{ + coordination_table_has_column, initialize_coordination_schema, validate_coordination_agent_id, + COORDINATION_SCHEMA_VERSION, +}; +use openbitfun_core_types::validate_session_id; +use openbitfun_legacy_migration::{ + atomic_write_bytes, atomic_write_json, snapshot_sqlite_read_only, validate_sqlite, + DomainContext, DomainScan, LegacyDomainAdapter, LegacyMigrationError, LegacyMigrationResult, + MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + ConflictResolution, FindingSeverity, MigrationConflict, MigrationDomainId, + MigrationDomainResult, MigrationDomainState, ScanFinding, +}; +use rusqlite::{params, Connection, OpenFlags, OptionalExtension, Transaction}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const COORDINATION_RELATIVE_PATH: &str = "data/agent-runtime/coordination.sqlite"; + +pub(crate) struct AgentCoordinationAdapter; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoordinationManifest { + source_digest: String, + target_existed: bool, + target_digest: Option, + merged_digest: String, + imported: u64, + skipped: u64, + conflicts: u64, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct CoordinationData { + sessions: Vec, + agents: Vec, + tasks: Vec, + swarm_trees: Vec, + swarm_nodes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct CoordinationSessionRow { + parent_session_id: String, + next_auto_agent_seq: i64, + updated_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRow { + agent_pk: i64, + parent_session_id: String, + agent_id: String, + child_session_id: Option, + next_bg_seq: i64, + state: String, + created_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct BackgroundTaskRow { + task_pk: i64, + parent_session_id: String, + agent_pk: i64, + bg_task_id: String, + bg_ordinal: i64, + parent_dialog_turn_id: String, + parent_tool_call_id: String, + child_dialog_turn_id: String, + status: String, + error_code: Option, + error_message: Option, + execution_owner_token: String, + created_at_ms: i64, + terminal_at_ms: Option, + delivered_at_ms: Option, + delivered_parent_dialog_turn_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct SwarmTreeRow { + root_session_id: String, + created_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct SwarmNodeRow { + session_id: String, + root_session_id: String, + parent_session_id: Option, + agent_type: String, + depth: i64, + created_at_ms: i64, +} + +#[derive(Default)] +struct MergeOutcome { + imported: u64, + duplicate: u64, + target_wins: u64, + conflicts: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AgentMergeTarget { + Imported(i64), + Existing(i64), + Rejected, +} + +impl AgentMergeTarget { + fn agent_pk(self) -> Option { + match self { + Self::Imported(agent_pk) | Self::Existing(agent_pk) => Some(agent_pk), + Self::Rejected => None, + } + } +} + +impl LegacyDomainAdapter for AgentCoordinationAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::AgentCoordination + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let source_path = source_coordination_path(roots); + validate_regular_file(&roots.legacy_user_root, &source_path).map_err(|_| { + LegacyMigrationError::UnsupportedSource( + "the selected Session group requires a readable legacy coordination.sqlite" + .to_string(), + ) + })?; + let source = load_coordination_data(&source_path, DatabaseRole::LegacySource)?; + let target_path = target_coordination_path(roots); + let target = if target_path.exists() { + validate_regular_file(&roots.target_user_root, &target_path)?; + load_coordination_data(&target_path, DatabaseRole::CurrentTarget)? + } else { + CoordinationData::default() + }; + let blocked_sessions = target_wins_session_ids(roots)?; + let conflicts = preview_conflicts(&source, &target, &blocked_sessions); + let entity_count = coordination_entity_count(&source); + let logical_bytes = fs::metadata(&source_path) + .map_err(|error| io_error(&source_path, error))? + .len(); + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: "legacy_agent_coordination_supported".to_string(), + severity: if conflicts.is_empty() { + FindingSeverity::Info + } else { + FindingSeverity::Warning + }, + entity_count, + logical_bytes, + source_schema: Some(format!( + "bitfun.agent-coordination.v{}", + schema_version(&source_path)? + )), + migratable: true, + detail: format!("{entity_count} Agent coordination records are owner-readable"), + }, + conflicts, + target_schema: Some(format!( + "openbitfun.agent-coordination.v{COORDINATION_SCHEMA_VERSION}" + )), + dependencies: vec![MigrationDomainId::WorkspaceSessions], + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let domain_root = stage_domain_dir(context, "agent-coordination"); + let staged_source = domain_root.join("source.sqlite"); + let staged_target = domain_root.join("target-before.sqlite"); + let staged_merged = domain_root.join("merged.sqlite"); + reset_stage_file(&staged_source)?; + reset_stage_file(&staged_target)?; + reset_stage_file(&staged_merged)?; + + let source_path = source_coordination_path(context.roots); + validate_regular_file(&context.roots.legacy_user_root, &source_path)?; + snapshot_sqlite_read_only(&source_path, &staged_source)?; + initialize_snapshot(&staged_source)?; + let source = load_coordination_data(&staged_source, DatabaseRole::StagedCurrent)?; + let workspace_manifest = read_workspace_sessions_manifest(context)?; + validate_source_cross_references(&source, &workspace_manifest)?; + let blocked_sessions = manifest_target_wins_session_ids(&workspace_manifest); + + let target_path = target_coordination_path(context.roots); + let target_existed = target_path.exists(); + let target_digest = if target_existed { + validate_regular_file(&context.roots.target_user_root, &target_path)?; + snapshot_sqlite_read_only(&target_path, &staged_target)?; + initialize_snapshot(&staged_target)?; + snapshot_sqlite_read_only(&staged_target, &staged_merged)?; + Some(coordination_digest(&load_coordination_data( + &staged_target, + DatabaseRole::StagedCurrent, + )?)?) + } else { + let connection = Connection::open(&staged_merged) + .map_err(|error| db_error(&staged_merged, error))?; + initialize_coordination_schema(&connection) + .map_err(|error| owner_error("initialize staged coordination database", error))?; + None + }; + + let outcome = + merge_coordination_database(&staged_source, &staged_merged, &blocked_sessions)?; + validate_sqlite(&staged_merged)?; + validate_current_database(&staged_merged)?; + let merged = load_coordination_data(&staged_merged, DatabaseRole::StagedCurrent)?; + let manifest = CoordinationManifest { + source_digest: coordination_digest(&source)?, + target_existed, + target_digest, + merged_digest: coordination_digest(&merged)?, + imported: outcome.imported, + skipped: outcome.duplicate.saturating_add(outcome.target_wins), + conflicts: outcome.target_wins, + }; + atomic_write_json(&coordination_manifest_path(context), &manifest)?; + + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported: manifest.imported, + skipped: manifest.skipped, + conflicts: manifest.conflicts, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_coordination_manifest(context)?; + let domain_root = stage_domain_dir(context, "agent-coordination"); + let source_path = domain_root.join("source.sqlite"); + let merged_path = domain_root.join("merged.sqlite"); + validate_sqlite(&source_path)?; + validate_sqlite(&merged_path)?; + validate_current_database(&merged_path)?; + let source = load_coordination_data(&source_path, DatabaseRole::StagedCurrent)?; + if coordination_digest(&source)? != manifest.source_digest { + return Err(LegacyMigrationError::InvalidRequest( + "staged Agent coordination source changed after snapshot".to_string(), + )); + } + let merged = load_coordination_data(&merged_path, DatabaseRole::StagedCurrent)?; + if coordination_digest(&merged)? != manifest.merged_digest { + return Err(LegacyMigrationError::InvalidRequest( + "staged Agent coordination merge differs from its manifest".to_string(), + )); + } + validate_source_cross_references(&source, &read_workspace_sessions_manifest(context)?) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_coordination_manifest(context)?; + let target = target_coordination_path(context.roots); + if target.exists() { + validate_regular_file(&context.roots.target_user_root, &target)?; + if validate_current_database(&target).is_ok() { + let current = load_coordination_data(&target, DatabaseRole::CurrentTarget)?; + if coordination_digest(¤t)? == manifest.merged_digest { + finalize_sqlite_file(&target)?; + return Ok(()); + } + } + } + verify_target_state(&target, &manifest)?; + let backup = backup_domain_dir(context, "agent-coordination").join("coordination.sqlite"); + if manifest.target_existed && !backup.exists() { + snapshot_sqlite_read_only(&target, &backup)?; + } + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; + } + let staged_source = stage_domain_dir(context, "agent-coordination").join("source.sqlite"); + let blocked_sessions = + manifest_target_wins_session_ids(&read_workspace_sessions_manifest(context)?); + merge_coordination_database(&staged_source, &target, &blocked_sessions)?; + finalize_sqlite_file(&target)?; + validate_current_database(&target)?; + let merged = load_coordination_data(&target, DatabaseRole::CurrentTarget)?; + if coordination_digest(&merged)? != manifest.merged_digest { + return Err(LegacyMigrationError::InvalidRequest( + "committed Agent coordination database differs from the staged merge".to_string(), + )); + } + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + validate_committed_coordination_cross_references(context) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let Some(manifest) = read_optional_bounded_json::( + &context.layout.stage_root(), + &coordination_manifest_path(context), + )? + else { + return Ok(()); + }; + let target = target_coordination_path(context.roots); + remove_sqlite_sidecars(&target)?; + let backup = backup_domain_dir(context, "agent-coordination").join("coordination.sqlite"); + if manifest.target_existed { + if backup.exists() { + let bytes = fs::read(&backup).map_err(|error| io_error(&backup, error))?; + atomic_write_bytes(&target, &bytes)?; + } + } else { + remove_file_if_present(&target)?; + } + remove_sqlite_sidecars(&target) + } +} + +pub(crate) fn validate_committed_coordination_cross_references( + context: &DomainContext<'_>, +) -> LegacyMigrationResult<()> { + let manifest = read_coordination_manifest(context)?; + let target = target_coordination_path(context.roots); + validate_sqlite(&target)?; + validate_current_database(&target)?; + let actual = load_coordination_data(&target, DatabaseRole::CurrentTarget)?; + if coordination_digest(&actual)? != manifest.merged_digest { + return Err(LegacyMigrationError::InvalidRequest( + "committed Agent coordination database changed before cross-reference validation" + .to_string(), + )); + } + let source = load_coordination_data( + &stage_domain_dir(context, "agent-coordination").join("source.sqlite"), + DatabaseRole::StagedCurrent, + )?; + validate_source_cross_references(&source, &read_workspace_sessions_manifest(context)?) +} + +fn source_coordination_path(roots: &MigrationRoots) -> PathBuf { + roots.legacy_user_root.join(COORDINATION_RELATIVE_PATH) +} + +fn target_coordination_path(roots: &MigrationRoots) -> PathBuf { + roots.target_user_root.join(COORDINATION_RELATIVE_PATH) +} + +fn coordination_manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "agent-coordination").join("manifest.json") +} + +fn read_coordination_manifest( + context: &DomainContext<'_>, +) -> LegacyMigrationResult { + read_bounded_json( + &context.layout.stage_root(), + &coordination_manifest_path(context), + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DatabaseRole { + LegacySource, + CurrentTarget, + StagedCurrent, +} + +fn load_coordination_data( + path: &Path, + role: DatabaseRole, +) -> LegacyMigrationResult { + validate_sqlite(path)?; + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| db_error(path, error))?; + let version = connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) + .map_err(|error| db_error(path, error))?; + match role { + DatabaseRole::LegacySource if !(1..=COORDINATION_SCHEMA_VERSION).contains(&version) => { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "legacy Agent coordination schema {version} is not supported" + ))); + } + DatabaseRole::CurrentTarget if !(0..=COORDINATION_SCHEMA_VERSION).contains(&version) => { + return Err(LegacyMigrationError::InvalidRequest(format!( + "target Agent coordination schema {version} is not supported" + ))); + } + DatabaseRole::StagedCurrent if version != COORDINATION_SCHEMA_VERSION => { + return Err(LegacyMigrationError::InvalidRequest(format!( + "staged Agent coordination schema {version} is not current" + ))); + } + _ => {} + } + if version == 0 && role == DatabaseRole::CurrentTarget { + return Ok(CoordinationData::default()); + } + validate_required_tables(&connection, path, version)?; + let delivered_columns = + coordination_table_has_column(&connection, "background_tasks", "delivered_at_ms") + .map_err(|error| owner_error("inspect coordination delivery columns", error))? + && coordination_table_has_column( + &connection, + "background_tasks", + "delivered_parent_dialog_turn_id", + ) + .map_err(|error| owner_error("inspect coordination delivery columns", error))?; + let sessions = query_rows( + &connection, + path, + "SELECT parent_session_id, next_auto_agent_seq, updated_at_ms FROM coordination_sessions ORDER BY parent_session_id", + |row| { + Ok(CoordinationSessionRow { + parent_session_id: row.get(0)?, + next_auto_agent_seq: row.get(1)?, + updated_at_ms: row.get(2)?, + }) + }, + )?; + let agents = query_rows( + &connection, + path, + "SELECT agent_pk, parent_session_id, agent_id, child_session_id, next_bg_seq, state, created_at_ms FROM agents ORDER BY agent_pk", + |row| { + Ok(AgentRow { + agent_pk: row.get(0)?, + parent_session_id: row.get(1)?, + agent_id: row.get(2)?, + child_session_id: row.get(3)?, + next_bg_seq: row.get(4)?, + state: row.get(5)?, + created_at_ms: row.get(6)?, + }) + }, + )?; + let task_sql = if delivered_columns { + "SELECT task_pk, parent_session_id, agent_pk, bg_task_id, bg_ordinal, parent_dialog_turn_id, parent_tool_call_id, child_dialog_turn_id, status, error_code, error_message, execution_owner_token, created_at_ms, terminal_at_ms, delivered_at_ms, delivered_parent_dialog_turn_id FROM background_tasks ORDER BY task_pk" + } else { + "SELECT task_pk, parent_session_id, agent_pk, bg_task_id, bg_ordinal, parent_dialog_turn_id, parent_tool_call_id, child_dialog_turn_id, status, error_code, error_message, execution_owner_token, created_at_ms, terminal_at_ms, NULL, NULL FROM background_tasks ORDER BY task_pk" + }; + let tasks = query_rows(&connection, path, task_sql, |row| { + Ok(BackgroundTaskRow { + task_pk: row.get(0)?, + parent_session_id: row.get(1)?, + agent_pk: row.get(2)?, + bg_task_id: row.get(3)?, + bg_ordinal: row.get(4)?, + parent_dialog_turn_id: row.get(5)?, + parent_tool_call_id: row.get(6)?, + child_dialog_turn_id: row.get(7)?, + status: row.get(8)?, + error_code: row.get(9)?, + error_message: row.get(10)?, + execution_owner_token: row.get(11)?, + created_at_ms: row.get(12)?, + terminal_at_ms: row.get(13)?, + delivered_at_ms: row.get(14)?, + delivered_parent_dialog_turn_id: row.get(15)?, + }) + })?; + let (swarm_trees, swarm_nodes) = if version >= 2 { + ( + query_rows( + &connection, + path, + "SELECT root_session_id, created_at_ms FROM swarm_trees ORDER BY root_session_id", + |row| { + Ok(SwarmTreeRow { + root_session_id: row.get(0)?, + created_at_ms: row.get(1)?, + }) + }, + )?, + query_rows( + &connection, + path, + "SELECT session_id, root_session_id, parent_session_id, agent_type, depth, created_at_ms FROM swarm_nodes ORDER BY depth, session_id", + |row| { + Ok(SwarmNodeRow { + session_id: row.get(0)?, + root_session_id: row.get(1)?, + parent_session_id: row.get(2)?, + agent_type: row.get(3)?, + depth: row.get(4)?, + created_at_ms: row.get(5)?, + }) + }, + )?, + ) + } else { + (Vec::new(), Vec::new()) + }; + let data = CoordinationData { + sessions, + agents, + tasks, + swarm_trees, + swarm_nodes, + }; + validate_coordination_data(&data)?; + Ok(data) +} + +fn query_rows( + connection: &Connection, + path: &Path, + sql: &str, + mapper: F, +) -> LegacyMigrationResult> +where + F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result, +{ + let mut statement = connection + .prepare(sql) + .map_err(|error| db_error(path, error))?; + let rows = statement + .query_map([], mapper) + .map_err(|error| db_error(path, error))?; + rows.collect::>>() + .map_err(|error| db_error(path, error)) +} + +fn validate_required_tables( + connection: &Connection, + path: &Path, + version: i64, +) -> LegacyMigrationResult<()> { + let mut expected = vec![ + ( + "coordination_sessions", + vec!["parent_session_id", "next_auto_agent_seq", "updated_at_ms"], + ), + ( + "agents", + vec![ + "agent_pk", + "parent_session_id", + "agent_id", + "child_session_id", + "next_bg_seq", + "state", + "created_at_ms", + ], + ), + ( + "background_tasks", + vec![ + "task_pk", + "parent_session_id", + "agent_pk", + "bg_task_id", + "bg_ordinal", + "parent_dialog_turn_id", + "parent_tool_call_id", + "child_dialog_turn_id", + "status", + "error_code", + "error_message", + "execution_owner_token", + "created_at_ms", + "terminal_at_ms", + ], + ), + ]; + if version >= 2 { + expected.extend([ + ("swarm_trees", vec!["root_session_id", "created_at_ms"]), + ( + "swarm_nodes", + vec![ + "session_id", + "root_session_id", + "parent_session_id", + "agent_type", + "depth", + "created_at_ms", + ], + ), + ]); + } + for (table, columns) in expected { + let actual = table_columns(connection, path, table)?; + if actual.is_empty() || columns.iter().any(|column| !actual.contains(*column)) { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "Agent coordination table {table} does not match the supported schema" + ))); + } + } + Ok(()) +} + +fn table_columns( + connection: &Connection, + path: &Path, + table: &str, +) -> LegacyMigrationResult> { + let mut statement = connection + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(|error| db_error(path, error))?; + let rows = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|error| db_error(path, error))?; + rows.collect::>>() + .map_err(|error| db_error(path, error)) +} + +fn validate_coordination_data(data: &CoordinationData) -> LegacyMigrationResult<()> { + let mut session_keys = HashSet::new(); + for row in &data.sessions { + validate_session(&row.parent_session_id)?; + if row.next_auto_agent_seq < 1 || row.updated_at_ms < 0 { + return unsupported("coordination Session counters or timestamps are invalid"); + } + if !session_keys.insert(row.parent_session_id.as_str()) { + return unsupported("coordination Session identities are not unique"); + } + } + + let mut agent_pks = HashSet::new(); + let mut agent_ids = HashSet::new(); + let mut child_ids = HashSet::new(); + for row in &data.agents { + validate_session(&row.parent_session_id)?; + if let Some(child) = row.child_session_id.as_deref() { + validate_session(child)?; + if !child_ids.insert((row.parent_session_id.as_str(), child)) { + return unsupported("Agent child Session identities are not unique per parent"); + } + } + if row.agent_pk <= 0 + || row.next_bg_seq < 1 + || row.created_at_ms < 0 + || !matches!(row.state.as_str(), "active" | "historical") + { + return unsupported("Agent coordination row contains invalid owner fields"); + } + validate_coordination_agent_id(&row.agent_id) + .map_err(|error| owner_error("validate legacy agent id", error))?; + if !agent_pks.insert(row.agent_pk) + || !agent_ids.insert((row.parent_session_id.as_str(), row.agent_id.as_str())) + { + return unsupported("Agent coordination identities are not unique"); + } + } + + let agents = data + .agents + .iter() + .map(|row| (row.agent_pk, row)) + .collect::>(); + let mut task_pks = HashSet::new(); + let mut task_ids = HashSet::new(); + let mut task_ordinals = HashSet::new(); + for row in &data.tasks { + validate_session(&row.parent_session_id)?; + let Some(agent) = agents.get(&row.agent_pk) else { + return unsupported("background Task references a missing Agent"); + }; + if agent.parent_session_id != row.parent_session_id { + return unsupported("background Task parent Session differs from its Agent"); + } + if row.task_pk <= 0 + || row.bg_task_id.is_empty() + || row.bg_ordinal < 1 + || row.parent_dialog_turn_id.is_empty() + || row.parent_tool_call_id.is_empty() + || row.child_dialog_turn_id.is_empty() + || row.execution_owner_token.is_empty() + || row.created_at_ms < 0 + || row.terminal_at_ms.is_some_and(|value| value < 0) + || row.delivered_at_ms.is_some_and(|value| value < 0) + || !matches!( + row.status.as_str(), + "running" + | "completed" + | "partial_timeout" + | "failed" + | "cancelled" + | "interrupted" + ) + { + return unsupported("background Task row contains invalid owner fields"); + } + if !task_pks.insert(row.task_pk) + || !task_ids.insert((row.parent_session_id.as_str(), row.bg_task_id.as_str())) + || !task_ordinals.insert((row.agent_pk, row.bg_ordinal)) + { + return unsupported("background Task identities are not unique"); + } + } + + let tree_ids = data + .swarm_trees + .iter() + .map(|row| row.root_session_id.as_str()) + .collect::>(); + if tree_ids.len() != data.swarm_trees.len() { + return unsupported("Swarm tree identities are not unique"); + } + for row in &data.swarm_trees { + validate_session(&row.root_session_id)?; + if row.created_at_ms < 0 { + return unsupported("Swarm tree timestamp is invalid"); + } + } + let nodes = data + .swarm_nodes + .iter() + .map(|row| (row.session_id.as_str(), row)) + .collect::>(); + if nodes.len() != data.swarm_nodes.len() { + return unsupported("Swarm node identities are not unique"); + } + for row in &data.swarm_nodes { + validate_session(&row.session_id)?; + validate_session(&row.root_session_id)?; + if !tree_ids.contains(row.root_session_id.as_str()) + || row.agent_type.trim().is_empty() + || row.depth < 0 + || row.created_at_ms < 0 + { + return unsupported("Swarm node contains invalid owner fields"); + } + match row.parent_session_id.as_deref() { + None if row.session_id == row.root_session_id && row.depth == 0 => {} + Some(parent_id) => { + let Some(parent) = nodes.get(parent_id) else { + return unsupported("Swarm node references a missing parent node"); + }; + if parent.root_session_id != row.root_session_id + || parent.depth.saturating_add(1) != row.depth + { + return unsupported("Swarm node lineage is inconsistent"); + } + } + _ => return unsupported("Swarm root node shape is invalid"), + } + } + Ok(()) +} + +fn validate_source_cross_references( + data: &CoordinationData, + manifest: &WorkspaceSessionsManifest, +) -> LegacyMigrationResult<()> { + let sessions = manifest + .sessions + .iter() + .map(|entry| (entry.session_id.as_str(), entry)) + .collect::>(); + let mut turns = HashMap::<&str, BTreeSet<&str>>::new(); + for entry in &manifest.sessions { + turns + .entry(entry.session_id.as_str()) + .or_default() + .extend(entry.turn_ids.iter().map(String::as_str)); + } + for entry in &manifest.runtime_events { + turns + .entry(entry.session_id.as_str()) + .or_default() + .extend(entry.turn_ids.iter().map(String::as_str)); + } + let require_session = |session_id: &str| -> LegacyMigrationResult<()> { + if sessions.contains_key(session_id) { + Ok(()) + } else { + unsupported(format!( + "Agent coordination references missing Session {session_id}" + )) + } + }; + let require_turn = |session_id: &str, turn_id: &str| -> LegacyMigrationResult<()> { + if turns + .get(session_id) + .is_some_and(|values| values.contains(turn_id)) + { + Ok(()) + } else { + unsupported(format!( + "Agent coordination references missing Turn {turn_id} in Session {session_id}" + )) + } + }; + for row in &data.sessions { + require_session(&row.parent_session_id)?; + } + let agents = data + .agents + .iter() + .map(|row| (row.agent_pk, row)) + .collect::>(); + for row in &data.agents { + require_session(&row.parent_session_id)?; + if let Some(child_id) = row.child_session_id.as_deref() { + require_session(child_id)?; + let relationship = sessions[child_id].relationship.as_ref().ok_or_else(|| { + LegacyMigrationError::UnsupportedSource(format!( + "Agent child Session {child_id} has no parent relationship" + )) + })?; + if relationship.parent_session_id.as_deref() != Some(&row.parent_session_id) { + return unsupported(format!( + "Agent child Session {child_id} has a different persisted parent" + )); + } + } + } + for row in &data.tasks { + let agent = agents[&row.agent_pk]; + let child_session_id = agent.child_session_id.as_deref().ok_or_else(|| { + LegacyMigrationError::UnsupportedSource(format!( + "background Task {} references an Agent without a child Session", + row.bg_task_id + )) + })?; + require_turn(&row.parent_session_id, &row.parent_dialog_turn_id)?; + require_turn(child_session_id, &row.child_dialog_turn_id)?; + if let Some(delivered_turn) = row.delivered_parent_dialog_turn_id.as_deref() { + require_turn(&row.parent_session_id, delivered_turn)?; + } + } + for row in &data.swarm_trees { + require_session(&row.root_session_id)?; + } + for row in &data.swarm_nodes { + require_session(&row.session_id)?; + require_session(&row.root_session_id)?; + if let Some(parent) = row.parent_session_id.as_deref() { + require_session(parent)?; + } + } + Ok(()) +} + +fn merge_coordination_database( + source_path: &Path, + target_path: &Path, + blocked_sessions: &BTreeSet, +) -> LegacyMigrationResult { + let source = load_coordination_data(source_path, DatabaseRole::StagedCurrent)?; + let mut connection = + Connection::open(target_path).map_err(|error| db_error(target_path, error))?; + initialize_coordination_schema(&connection) + .map_err(|error| owner_error("initialize target coordination database", error))?; + connection + .execute_batch("PRAGMA foreign_keys = ON;") + .map_err(|error| db_error(target_path, error))?; + let transaction = connection + .transaction() + .map_err(|error| db_error(target_path, error))?; + let mut outcome = MergeOutcome::default(); + merge_coordination_sessions( + &transaction, + &source.sessions, + blocked_sessions, + &mut outcome, + target_path, + )?; + let agent_map = merge_agents( + &transaction, + &source.agents, + blocked_sessions, + &mut outcome, + target_path, + )?; + merge_background_tasks( + &transaction, + &source.tasks, + &agent_map, + blocked_sessions, + &mut outcome, + target_path, + )?; + merge_swarm_trees( + &transaction, + &source.swarm_trees, + blocked_sessions, + &mut outcome, + target_path, + )?; + merge_swarm_nodes( + &transaction, + &source.swarm_nodes, + blocked_sessions, + &mut outcome, + target_path, + )?; + transaction + .commit() + .map_err(|error| db_error(target_path, error))?; + Ok(outcome) +} + +fn merge_coordination_sessions( + transaction: &Transaction<'_>, + rows: &[CoordinationSessionRow], + blocked_sessions: &BTreeSet, + outcome: &mut MergeOutcome, + path: &Path, +) -> LegacyMigrationResult<()> { + for row in rows { + if blocked_sessions.contains(&row.parent_session_id) { + record_target_win( + outcome, + "coordination_session_data_target_wins", + &row.parent_session_id, + ); + continue; + } + let existing = transaction + .query_row( + "SELECT parent_session_id, next_auto_agent_seq, updated_at_ms FROM coordination_sessions WHERE parent_session_id = ?1", + params![row.parent_session_id], + |record| Ok(CoordinationSessionRow { + parent_session_id: record.get(0)?, + next_auto_agent_seq: record.get(1)?, + updated_at_ms: record.get(2)?, + }), + ) + .optional() + .map_err(|error| db_error(path, error))?; + match existing { + Some(existing) if existing == *row => outcome.duplicate += 1, + Some(_) => record_target_win( + outcome, + "coordination_session_target_wins", + &row.parent_session_id, + ), + None => { + transaction.execute( + "INSERT INTO coordination_sessions (parent_session_id, next_auto_agent_seq, updated_at_ms) VALUES (?1, ?2, ?3)", + params![row.parent_session_id, row.next_auto_agent_seq, row.updated_at_ms], + ).map_err(|error| db_error(path, error))?; + outcome.imported += 1; + } + } + } + Ok(()) +} + +fn merge_agents( + transaction: &Transaction<'_>, + rows: &[AgentRow], + blocked_sessions: &BTreeSet, + outcome: &mut MergeOutcome, + path: &Path, +) -> LegacyMigrationResult> { + let mut mapping = HashMap::new(); + for row in rows { + if blocked_sessions.contains(&row.parent_session_id) + || row + .child_session_id + .as_ref() + .is_some_and(|session_id| blocked_sessions.contains(session_id)) + { + mapping.insert(row.agent_pk, AgentMergeTarget::Rejected); + record_target_win( + outcome, + "coordination_agent_session_target_wins", + &format!("{}:{}", row.parent_session_id, row.agent_id), + ); + continue; + } + let mut candidates = BTreeSet::new(); + if let Some(pk) = transaction + .query_row( + "SELECT agent_pk FROM agents WHERE parent_session_id = ?1 AND agent_id = ?2", + params![row.parent_session_id, row.agent_id], + |record| record.get::<_, i64>(0), + ) + .optional() + .map_err(|error| db_error(path, error))? + { + candidates.insert(pk); + } + if let Some(child) = row.child_session_id.as_deref() { + if let Some(pk) = transaction.query_row( + "SELECT agent_pk FROM agents WHERE parent_session_id = ?1 AND child_session_id = ?2", + params![row.parent_session_id, child], + |record| record.get::<_, i64>(0), + ).optional().map_err(|error| db_error(path, error))? { + candidates.insert(pk); + } + } + if candidates.is_empty() { + transaction.execute( + "INSERT INTO agents (parent_session_id, agent_id, child_session_id, next_bg_seq, state, created_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![row.parent_session_id, row.agent_id, row.child_session_id, row.next_bg_seq, row.state, row.created_at_ms], + ).map_err(|error| db_error(path, error))?; + let pk = transaction.last_insert_rowid(); + mapping.insert(row.agent_pk, AgentMergeTarget::Imported(pk)); + outcome.imported += 1; + continue; + } + if candidates.len() == 1 { + let pk = *candidates.iter().next().expect("one candidate exists"); + let existing = load_agent_by_pk(transaction, path, pk)?; + if agent_logically_equal(&existing, row) { + mapping.insert(row.agent_pk, AgentMergeTarget::Existing(pk)); + outcome.duplicate += 1; + continue; + } + } + mapping.insert(row.agent_pk, AgentMergeTarget::Rejected); + record_target_win( + outcome, + "coordination_agent_target_wins", + &format!("{}:{}", row.parent_session_id, row.agent_id), + ); + } + Ok(mapping) +} + +fn merge_background_tasks( + transaction: &Transaction<'_>, + rows: &[BackgroundTaskRow], + agent_map: &HashMap, + blocked_sessions: &BTreeSet, + outcome: &mut MergeOutcome, + path: &Path, +) -> LegacyMigrationResult<()> { + for row in rows { + if blocked_sessions.contains(&row.parent_session_id) { + record_target_win( + outcome, + "coordination_task_session_target_wins", + &row.bg_task_id, + ); + continue; + } + let Some(agent_target) = agent_map.get(&row.agent_pk).copied() else { + return unsupported("background Task Agent mapping was not constructed"); + }; + let Some(agent_pk) = agent_target.agent_pk() else { + record_target_win( + outcome, + "coordination_task_agent_target_wins", + &row.bg_task_id, + ); + continue; + }; + let by_id = transaction.query_row( + "SELECT task_pk FROM background_tasks WHERE parent_session_id = ?1 AND bg_task_id = ?2", + params![row.parent_session_id, row.bg_task_id], + |record| record.get::<_, i64>(0), + ).optional().map_err(|error| db_error(path, error))?; + let by_ordinal = transaction + .query_row( + "SELECT task_pk FROM background_tasks WHERE agent_pk = ?1 AND bg_ordinal = ?2", + params![agent_pk, row.bg_ordinal], + |record| record.get::<_, i64>(0), + ) + .optional() + .map_err(|error| db_error(path, error))?; + let candidates = [by_id, by_ordinal] + .into_iter() + .flatten() + .collect::>(); + if candidates.is_empty() { + transaction.execute( + "INSERT INTO background_tasks (parent_session_id, agent_pk, bg_task_id, bg_ordinal, parent_dialog_turn_id, parent_tool_call_id, child_dialog_turn_id, status, error_code, error_message, execution_owner_token, created_at_ms, terminal_at_ms, delivered_at_ms, delivered_parent_dialog_turn_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + params![row.parent_session_id, agent_pk, row.bg_task_id, row.bg_ordinal, row.parent_dialog_turn_id, row.parent_tool_call_id, row.child_dialog_turn_id, row.status, row.error_code, row.error_message, row.execution_owner_token, row.created_at_ms, row.terminal_at_ms, row.delivered_at_ms, row.delivered_parent_dialog_turn_id], + ).map_err(|error| db_error(path, error))?; + outcome.imported += 1; + } else if candidates.len() == 1 { + let existing = load_task_by_pk( + transaction, + path, + *candidates.iter().next().expect("one candidate exists"), + )?; + if task_logically_equal(&existing, row, agent_pk) { + outcome.duplicate += 1; + } else { + record_target_win(outcome, "coordination_task_target_wins", &row.bg_task_id); + } + } else { + record_target_win(outcome, "coordination_task_target_wins", &row.bg_task_id); + } + } + Ok(()) +} + +fn merge_swarm_trees( + transaction: &Transaction<'_>, + rows: &[SwarmTreeRow], + blocked_sessions: &BTreeSet, + outcome: &mut MergeOutcome, + path: &Path, +) -> LegacyMigrationResult<()> { + for row in rows { + if blocked_sessions.contains(&row.root_session_id) { + record_target_win( + outcome, + "coordination_swarm_tree_session_target_wins", + &row.root_session_id, + ); + continue; + } + let existing = transaction + .query_row( + "SELECT root_session_id, created_at_ms FROM swarm_trees WHERE root_session_id = ?1", + params![row.root_session_id], + |record| { + Ok(SwarmTreeRow { + root_session_id: record.get(0)?, + created_at_ms: record.get(1)?, + }) + }, + ) + .optional() + .map_err(|error| db_error(path, error))?; + match existing { + Some(existing) if existing == *row => outcome.duplicate += 1, + Some(_) => record_target_win( + outcome, + "coordination_swarm_tree_target_wins", + &row.root_session_id, + ), + None => { + transaction + .execute( + "INSERT INTO swarm_trees (root_session_id, created_at_ms) VALUES (?1, ?2)", + params![row.root_session_id, row.created_at_ms], + ) + .map_err(|error| db_error(path, error))?; + outcome.imported += 1; + } + } + } + Ok(()) +} + +fn merge_swarm_nodes( + transaction: &Transaction<'_>, + rows: &[SwarmNodeRow], + blocked_sessions: &BTreeSet, + outcome: &mut MergeOutcome, + path: &Path, +) -> LegacyMigrationResult<()> { + for row in rows { + if blocked_sessions.contains(&row.session_id) + || blocked_sessions.contains(&row.root_session_id) + || row + .parent_session_id + .as_ref() + .is_some_and(|session_id| blocked_sessions.contains(session_id)) + { + record_target_win( + outcome, + "coordination_swarm_node_session_target_wins", + &row.session_id, + ); + continue; + } + let existing = transaction.query_row( + "SELECT session_id, root_session_id, parent_session_id, agent_type, depth, created_at_ms FROM swarm_nodes WHERE session_id = ?1", + params![row.session_id], + |record| Ok(SwarmNodeRow { session_id: record.get(0)?, root_session_id: record.get(1)?, parent_session_id: record.get(2)?, agent_type: record.get(3)?, depth: record.get(4)?, created_at_ms: record.get(5)? }), + ).optional().map_err(|error| db_error(path, error))?; + match existing { + Some(existing) if existing == *row => outcome.duplicate += 1, + Some(_) => record_target_win( + outcome, + "coordination_swarm_node_target_wins", + &row.session_id, + ), + None => { + transaction.execute( + "INSERT INTO swarm_nodes (session_id, root_session_id, parent_session_id, agent_type, depth, created_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![row.session_id, row.root_session_id, row.parent_session_id, row.agent_type, row.depth, row.created_at_ms], + ).map_err(|error| db_error(path, error))?; + outcome.imported += 1; + } + } + } + Ok(()) +} + +fn load_agent_by_pk( + transaction: &Transaction<'_>, + path: &Path, + agent_pk: i64, +) -> LegacyMigrationResult { + transaction.query_row( + "SELECT agent_pk, parent_session_id, agent_id, child_session_id, next_bg_seq, state, created_at_ms FROM agents WHERE agent_pk = ?1", + params![agent_pk], + |record| Ok(AgentRow { agent_pk: record.get(0)?, parent_session_id: record.get(1)?, agent_id: record.get(2)?, child_session_id: record.get(3)?, next_bg_seq: record.get(4)?, state: record.get(5)?, created_at_ms: record.get(6)? }), + ).map_err(|error| db_error(path, error)) +} + +fn load_task_by_pk( + transaction: &Transaction<'_>, + path: &Path, + task_pk: i64, +) -> LegacyMigrationResult { + transaction.query_row( + "SELECT task_pk, parent_session_id, agent_pk, bg_task_id, bg_ordinal, parent_dialog_turn_id, parent_tool_call_id, child_dialog_turn_id, status, error_code, error_message, execution_owner_token, created_at_ms, terminal_at_ms, delivered_at_ms, delivered_parent_dialog_turn_id FROM background_tasks WHERE task_pk = ?1", + params![task_pk], + |record| Ok(BackgroundTaskRow { task_pk: record.get(0)?, parent_session_id: record.get(1)?, agent_pk: record.get(2)?, bg_task_id: record.get(3)?, bg_ordinal: record.get(4)?, parent_dialog_turn_id: record.get(5)?, parent_tool_call_id: record.get(6)?, child_dialog_turn_id: record.get(7)?, status: record.get(8)?, error_code: record.get(9)?, error_message: record.get(10)?, execution_owner_token: record.get(11)?, created_at_ms: record.get(12)?, terminal_at_ms: record.get(13)?, delivered_at_ms: record.get(14)?, delivered_parent_dialog_turn_id: record.get(15)? }), + ).map_err(|error| db_error(path, error)) +} + +fn agent_logically_equal(left: &AgentRow, right: &AgentRow) -> bool { + left.parent_session_id == right.parent_session_id + && left.agent_id == right.agent_id + && left.child_session_id == right.child_session_id + && left.next_bg_seq == right.next_bg_seq + && left.state == right.state + && left.created_at_ms == right.created_at_ms +} + +fn task_logically_equal( + left: &BackgroundTaskRow, + right: &BackgroundTaskRow, + agent_pk: i64, +) -> bool { + left.parent_session_id == right.parent_session_id + && left.agent_pk == agent_pk + && left.bg_task_id == right.bg_task_id + && left.bg_ordinal == right.bg_ordinal + && left.parent_dialog_turn_id == right.parent_dialog_turn_id + && left.parent_tool_call_id == right.parent_tool_call_id + && left.child_dialog_turn_id == right.child_dialog_turn_id + && left.status == right.status + && left.error_code == right.error_code + && left.error_message == right.error_message + && left.execution_owner_token == right.execution_owner_token + && left.created_at_ms == right.created_at_ms + && left.terminal_at_ms == right.terminal_at_ms + && left.delivered_at_ms == right.delivered_at_ms + && left.delivered_parent_dialog_turn_id == right.delivered_parent_dialog_turn_id +} + +fn preview_conflicts( + source: &CoordinationData, + target: &CoordinationData, + blocked_sessions: &BTreeSet, +) -> Vec { + let mut conflicts = Vec::new(); + let target_sessions = target + .sessions + .iter() + .map(|row| (&row.parent_session_id, row)) + .collect::>(); + for row in &source.sessions { + if blocked_sessions.contains(&row.parent_session_id) { + conflicts.push(conflict( + "coordination_session_data_target_wins", + &row.parent_session_id, + )); + } else if target_sessions + .get(&row.parent_session_id) + .is_some_and(|existing| *existing != row) + { + conflicts.push(conflict( + "coordination_session_target_wins", + &row.parent_session_id, + )); + } + } + + let mut agent_map = HashMap::new(); + for row in &source.agents { + if blocked_sessions.contains(&row.parent_session_id) + || row + .child_session_id + .as_ref() + .is_some_and(|session_id| blocked_sessions.contains(session_id)) + { + agent_map.insert(row.agent_pk, AgentMergeTarget::Rejected); + conflicts.push(conflict( + "coordination_agent_session_target_wins", + &format!("{}:{}", row.parent_session_id, row.agent_id), + )); + continue; + } + + let candidates = target + .agents + .iter() + .filter(|existing| { + existing.parent_session_id == row.parent_session_id + && (existing.agent_id == row.agent_id + || (row.child_session_id.is_some() + && existing.child_session_id == row.child_session_id)) + }) + .collect::>(); + match candidates.as_slice() { + [] => { + agent_map.insert(row.agent_pk, AgentMergeTarget::Imported(row.agent_pk)); + } + [existing] if agent_logically_equal(existing, row) => { + agent_map.insert(row.agent_pk, AgentMergeTarget::Existing(existing.agent_pk)); + } + _ => { + agent_map.insert(row.agent_pk, AgentMergeTarget::Rejected); + conflicts.push(conflict( + "coordination_agent_target_wins", + &format!("{}:{}", row.parent_session_id, row.agent_id), + )); + } + } + } + + for row in &source.tasks { + if blocked_sessions.contains(&row.parent_session_id) { + conflicts.push(conflict( + "coordination_task_session_target_wins", + &row.bg_task_id, + )); + continue; + } + let Some(agent_target) = agent_map.get(&row.agent_pk).copied() else { + continue; + }; + let agent_pk = match agent_target { + AgentMergeTarget::Rejected => { + conflicts.push(conflict( + "coordination_task_agent_target_wins", + &row.bg_task_id, + )); + continue; + } + AgentMergeTarget::Imported(_) => None, + AgentMergeTarget::Existing(agent_pk) => Some(agent_pk), + }; + let candidates = target + .tasks + .iter() + .filter(|existing| { + (existing.parent_session_id == row.parent_session_id + && existing.bg_task_id == row.bg_task_id) + || agent_pk.is_some_and(|agent_pk| { + existing.agent_pk == agent_pk && existing.bg_ordinal == row.bg_ordinal + }) + }) + .collect::>(); + let duplicate = match (candidates.as_slice(), agent_pk) { + ([existing], Some(agent_pk)) => task_logically_equal(existing, row, agent_pk), + _ => false, + }; + if !candidates.is_empty() && !duplicate { + conflicts.push(conflict("coordination_task_target_wins", &row.bg_task_id)); + } + } + + let target_trees = target + .swarm_trees + .iter() + .map(|row| (&row.root_session_id, row)) + .collect::>(); + for row in &source.swarm_trees { + if blocked_sessions.contains(&row.root_session_id) { + conflicts.push(conflict( + "coordination_swarm_tree_session_target_wins", + &row.root_session_id, + )); + } else if target_trees + .get(&row.root_session_id) + .is_some_and(|existing| *existing != row) + { + conflicts.push(conflict( + "coordination_swarm_tree_target_wins", + &row.root_session_id, + )); + } + } + + let target_nodes = target + .swarm_nodes + .iter() + .map(|row| (&row.session_id, row)) + .collect::>(); + for row in &source.swarm_nodes { + if blocked_sessions.contains(&row.session_id) + || blocked_sessions.contains(&row.root_session_id) + || row + .parent_session_id + .as_ref() + .is_some_and(|session_id| blocked_sessions.contains(session_id)) + { + conflicts.push(conflict( + "coordination_swarm_node_session_target_wins", + &row.session_id, + )); + } else if target_nodes + .get(&row.session_id) + .is_some_and(|existing| *existing != row) + { + conflicts.push(conflict( + "coordination_swarm_node_target_wins", + &row.session_id, + )); + } + } + conflicts +} + +fn record_target_win(outcome: &mut MergeOutcome, code: &'static str, logical_id: &str) { + outcome.target_wins = outcome.target_wins.saturating_add(1); + outcome.conflicts.push(conflict(code, logical_id)); +} + +fn conflict(code: &'static str, logical_id: &str) -> MigrationConflict { + MigrationConflict { + domain: MigrationDomainId::AgentCoordination, + code: code.to_string(), + source_summary: format!("legacy Agent coordination record {logical_id}"), + target_summary: format!("current Agent coordination record {logical_id}"), + resolution: ConflictResolution::TargetWins, + } +} + +fn verify_target_state(path: &Path, manifest: &CoordinationManifest) -> LegacyMigrationResult<()> { + if path.exists() != manifest.target_existed { + return Err(LegacyMigrationError::InvalidRequest(format!( + "target changed after staging: {}", + path.display() + ))); + } + if let Some(expected) = manifest.target_digest.as_deref() { + let actual = + coordination_digest(&load_coordination_data(path, DatabaseRole::CurrentTarget)?)?; + if actual != expected { + return Err(LegacyMigrationError::InvalidRequest(format!( + "target changed after staging: {}", + path.display() + ))); + } + } + Ok(()) +} + +fn initialize_snapshot(path: &Path) -> LegacyMigrationResult<()> { + let connection = Connection::open(path).map_err(|error| db_error(path, error))?; + initialize_coordination_schema(&connection) + .map_err(|error| owner_error("upgrade staged coordination snapshot", error)) +} + +fn finalize_sqlite_file(path: &Path) -> LegacyMigrationResult<()> { + let connection = Connection::open(path).map_err(|error| db_error(path, error))?; + connection + .execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode = DELETE;") + .map_err(|error| db_error(path, error))?; + drop(connection); + remove_sqlite_sidecars(path) +} + +fn validate_current_database(path: &Path) -> LegacyMigrationResult<()> { + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| db_error(path, error))?; + let version = connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) + .map_err(|error| db_error(path, error))?; + if version != COORDINATION_SCHEMA_VERSION { + return Err(LegacyMigrationError::InvalidRequest(format!( + "Agent coordination database is schema {version}, expected {COORDINATION_SCHEMA_VERSION}" + ))); + } + validate_required_tables(&connection, path, version)?; + for column in ["delivered_at_ms", "delivered_parent_dialog_turn_id"] { + if !coordination_table_has_column(&connection, "background_tasks", column) + .map_err(|error| owner_error("validate coordination schema", error))? + { + return Err(LegacyMigrationError::InvalidRequest(format!( + "Agent coordination database is missing {column}" + ))); + } + } + let foreign_key_error: Option = connection + .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) + .optional() + .map_err(|error| db_error(path, error))?; + if foreign_key_error.is_some() { + return Err(LegacyMigrationError::InvalidRequest( + "Agent coordination foreign-key validation failed".to_string(), + )); + } + Ok(()) +} + +fn schema_version(path: &Path) -> LegacyMigrationResult { + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| db_error(path, error))?; + connection + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .map_err(|error| db_error(path, error)) +} + +fn coordination_digest(data: &CoordinationData) -> LegacyMigrationResult { + serde_json::to_vec(data) + .map(|bytes| format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) + .map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "failed to hash Agent coordination data: {error}" + )) + }) +} + +fn coordination_entity_count(data: &CoordinationData) -> u64 { + [ + data.sessions.len(), + data.agents.len(), + data.tasks.len(), + data.swarm_trees.len(), + data.swarm_nodes.len(), + ] + .into_iter() + .fold(0u64, |total, count| total.saturating_add(count as u64)) +} + +fn manifest_target_wins_session_ids(manifest: &WorkspaceSessionsManifest) -> BTreeSet { + manifest + .sessions + .iter() + .filter(|entry| entry.action == SessionImportAction::TargetWins) + .map(|entry| entry.session_id.clone()) + .collect() +} + +fn validate_session(session_id: &str) -> LegacyMigrationResult<()> { + validate_session_id(session_id).map_err(|error| { + LegacyMigrationError::UnsupportedSource(format!( + "Agent coordination contains an invalid Session id: {error}" + )) + }) +} + +fn reset_stage_file(path: &Path) -> LegacyMigrationResult<()> { + remove_file_if_present(path)?; + remove_sqlite_sidecars(path) +} + +fn remove_sqlite_sidecars(path: &Path) -> LegacyMigrationResult<()> { + for suffix in ["-wal", "-shm"] { + let sidecar = PathBuf::from(format!("{}{suffix}", path.display())); + remove_file_if_present(&sidecar)?; + } + Ok(()) +} + +fn remove_file_if_present(path: &Path) -> LegacyMigrationResult<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(io_error(path, error)), + } +} + +fn unsupported(detail: impl Into) -> LegacyMigrationResult { + Err(LegacyMigrationError::UnsupportedSource(detail.into())) +} + +fn owner_error(context: &str, error: impl std::fmt::Display) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!("{context}: {error}")) +} + +fn db_error(path: &Path, error: rusqlite::Error) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!( + "Agent coordination database error at {}: {error}", + path.display() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::legacy_migration::adapters_for_groups; + use crate::service::session_projection_format::validate_runtime_event_log; + use crate::service::workspace::persistence::{ + validate_workspace_persistence_data, WorkspacePersistenceData, + }; + use openbitfun_legacy_migration::{ + probe_legacy_source, CancellationToken, CrashInjector, CrashPoint, MigrationEngine, + NoCrashInjection, ProbeLimits, + }; + use openbitfun_product_domains::legacy_migration::{ + MigrationGroupId, MigrationRunStatus, MigrationSelection, + }; + use openbitfun_services_core::session::OfflineSessionImportStore; + use std::io::Read; + use std::sync::atomic::{AtomicBool, Ordering}; + + struct CrashOnce { + point: CrashPoint, + fired: AtomicBool, + } + + impl CrashInjector for CrashOnce { + fn should_crash(&self, point: CrashPoint) -> bool { + point == self.point && !self.fired.swap(true, Ordering::AcqRel) + } + } + + #[test] + fn session_group_migrates_owner_data_wal_and_relationship_closure() { + let temp = test_tempdir("session-group"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let wal_connection = materialize_coordination(&roots, true); + let source_hash = hash_source_roots(&roots); + let selection = session_selection(); + assert_eq!( + selection.expanded_domains(), + [ + MigrationDomainId::WorkspaceSessions, + MigrationDomainId::AgentCoordination, + MigrationDomainId::CrossReferenceRepair, + ] + ); + + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection.clone(), &CancellationToken::default()) + .unwrap(); + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert_eq!(report.status, MigrationRunStatus::CompletedWithWarnings); + assert!(report + .domain_results + .iter() + .all(|result| result.state == MigrationDomainState::Verified)); + + let workspace_path = roots.target_user_root.join("data/workspace_data.json"); + let workspace: WorkspacePersistenceData = + serde_json::from_slice(&fs::read(workspace_path).unwrap()).unwrap(); + validate_workspace_persistence_data( + &workspace, + &roots.target_user_root.join("data/miniapps"), + ) + .unwrap(); + assert_eq!(workspace.workspaces.len(), 1); + assert_eq!( + workspace.current_workspace_id, + workspace.opened_workspace_ids.first().cloned() + ); + + let sessions_root = roots + .target_home_root + .join("projects/c--fixture-workspace/sessions"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let store = OfflineSessionImportStore::new(&sessions_root); + let parent = runtime + .block_on(store.load_bundle("session-1")) + .unwrap() + .unwrap(); + let child = runtime + .block_on(store.load_bundle("session-child-1")) + .unwrap() + .unwrap(); + assert_eq!(parent.turns[0].turn_id, "turn-1"); + assert_eq!( + child + .metadata + .relationship + .as_ref() + .and_then(|relationship| relationship.parent_session_id.as_deref()), + Some("session-1") + ); + assert!(!sessions_root + .join("session-1/request-traces/trace.json") + .exists()); + assert!(!roots + .target_user_root + .join("data/agent-runtime/ownership") + .exists()); + assert!(!roots + .target_user_root + .join("data/agent-runtime/ipc-v17") + .exists()); + + let event_path = roots + .target_home_root + .join("runtime-events/session-1.jsonl"); + let event_summary = validate_runtime_event_log(&event_path, "session-1").unwrap(); + assert!(event_summary.turn_ids.contains("turn-runtime-1")); + + let target_coordination = target_coordination_path(&roots); + let coordination = + load_coordination_data(&target_coordination, DatabaseRole::CurrentTarget).unwrap(); + assert_eq!(coordination.sessions.len(), 2); + assert!(coordination + .sessions + .iter() + .any(|row| row.parent_session_id == "session-child-1")); + assert_eq!(coordination.agents.len(), 1); + assert_eq!(coordination.tasks.len(), 1); + assert_eq!(coordination.swarm_nodes.len(), 2); + assert!(!PathBuf::from(format!("{}-wal", target_coordination.display())).exists()); + assert!(!PathBuf::from(format!("{}-shm", target_coordination.display())).exists()); + let report_json = serde_json::to_string(&report).unwrap(); + assert!(!report_json.contains("Synthetic parent request")); + assert!(!report_json.contains("Synthetic delegated request")); + assert_eq!(hash_source_roots(&roots), source_hash); + + let second_source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let second_plan = engine + .plan(&second_source, selection, &CancellationToken::default()) + .unwrap(); + engine + .execute( + &second_plan, + &CancellationToken::default(), + &NoCrashInjection, + ) + .unwrap(); + let repeated = + load_coordination_data(&target_coordination, DatabaseRole::CurrentTarget).unwrap(); + assert_eq!( + coordination_entity_count(&repeated), + coordination_entity_count(&coordination) + ); + assert_eq!(hash_source_roots(&roots), source_hash); + drop(wal_connection); + } + + #[test] + fn missing_or_unknown_coordination_schema_blocks_the_session_plan() { + let temp = test_tempdir("coordination-schema"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let selection = session_selection(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let missing = engine.plan(&source, selection.clone(), &CancellationToken::default()); + assert!(missing + .unwrap_err() + .to_string() + .contains("requires a readable legacy coordination.sqlite")); + + let connection = materialize_coordination(&roots, false); + connection.pragma_update(None, "user_version", 99).unwrap(); + drop(connection); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let unknown = engine.plan(&source, selection, &CancellationToken::default()); + assert!(unknown + .unwrap_err() + .to_string() + .contains("schema 99 is not supported")); + } + + #[test] + fn target_session_and_coordination_records_win_without_cross_attaching_source_tasks() { + let temp = test_tempdir("coordination-target-wins"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_coordination(&roots, false); + drop(source_connection); + + let source_parent = roots + .legacy_home_root + .join("projects/c--fixture-workspace/sessions/session-1"); + let target_parent = roots + .target_home_root + .join("projects/c--fixture-workspace/sessions/session-1"); + copy_directory(&source_parent, &target_parent).unwrap(); + let metadata_path = target_parent.join("metadata.json"); + let mut metadata: serde_json::Value = + serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap(); + metadata["sessionName"] = serde_json::Value::String("Current target Session".to_string()); + atomic_write_json(&metadata_path, &metadata).unwrap(); + + let target_coordination = target_coordination_path(&roots); + fs::create_dir_all(target_coordination.parent().unwrap()).unwrap(); + let target_connection = Connection::open(&target_coordination).unwrap(); + initialize_coordination_schema(&target_connection).unwrap(); + target_connection.execute( + "INSERT INTO coordination_sessions (parent_session_id, next_auto_agent_seq, updated_at_ms) VALUES ('session-1', 99, 99)", + [], + ).unwrap(); + drop(target_connection); + + let selection = session_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let coordination_conflicts = plan + .conflicts + .iter() + .filter(|entry| entry.domain == MigrationDomainId::AgentCoordination) + .collect::>(); + assert_eq!(coordination_conflicts.len(), 6); + assert!(coordination_conflicts + .iter() + .any(|entry| entry.code == "coordination_session_data_target_wins")); + assert!(coordination_conflicts + .iter() + .any(|entry| entry.code == "coordination_agent_session_target_wins")); + assert!(coordination_conflicts + .iter() + .any(|entry| entry.code == "coordination_task_session_target_wins")); + assert!(coordination_conflicts + .iter() + .any(|entry| entry.code == "coordination_swarm_tree_session_target_wins")); + assert_eq!( + coordination_conflicts + .iter() + .filter(|entry| entry.code == "coordination_swarm_node_session_target_wins") + .count(), + 2 + ); + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert_eq!( + report + .domain_results + .iter() + .find(|result| result.domain == MigrationDomainId::AgentCoordination) + .unwrap() + .conflicts, + coordination_conflicts.len() as u64 + ); + + let target = + load_coordination_data(&target_coordination, DatabaseRole::CurrentTarget).unwrap(); + assert_eq!(target.sessions.len(), 1); + assert_eq!(target.sessions[0].next_auto_agent_seq, 99); + assert!(target.agents.is_empty()); + assert!(target.tasks.is_empty()); + assert!(target.swarm_trees.is_empty()); + assert!(target.swarm_nodes.is_empty()); + let stored: serde_json::Value = + serde_json::from_slice(&fs::read(metadata_path).unwrap()).unwrap(); + assert_eq!(stored["sessionName"], "Current target Session"); + } + + #[test] + fn preview_reports_task_and_swarm_conflicts_before_stage() { + let temp = test_tempdir("coordination-conflict-preview"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let connection = materialize_coordination(&roots, false); + drop(connection); + let source = load_coordination_data( + &source_coordination_path(&roots), + DatabaseRole::LegacySource, + ) + .unwrap(); + let mut target = source.clone(); + target.tasks[0].status = "failed".to_string(); + target.swarm_trees[0].created_at_ms += 1; + target.swarm_nodes[1].created_at_ms += 1; + + let conflicts = preview_conflicts(&source, &target, &BTreeSet::new()); + assert_eq!(conflicts.len(), 3); + assert!(conflicts + .iter() + .any(|entry| entry.code == "coordination_task_target_wins")); + assert!(conflicts + .iter() + .any(|entry| entry.code == "coordination_swarm_tree_target_wins")); + assert!(conflicts + .iter() + .any(|entry| entry.code == "coordination_swarm_node_target_wins")); + } + + #[test] + fn committed_session_domains_resume_idempotently_after_crash() { + for (label, crash_point) in [ + ( + "workspace-commit-recovery", + CrashPoint::AfterCommit(MigrationDomainId::WorkspaceSessions), + ), + ( + "coordination-commit-recovery", + CrashPoint::AfterCommit(MigrationDomainId::AgentCoordination), + ), + ] { + let temp = test_tempdir(label); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let connection = materialize_coordination(&roots, false); + drop(connection); + let source_hash = hash_source_roots(&roots); + let selection = session_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = + MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let crash = CrashOnce { + point: crash_point, + fired: AtomicBool::new(false), + }; + + let error = engine + .execute(&plan, &CancellationToken::default(), &crash) + .unwrap_err(); + assert!(matches!( + error, + LegacyMigrationError::InjectedCrash(actual) if actual == crash_point + )); + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert!(matches!( + report.status, + MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings + )); + assert!(report + .domain_results + .iter() + .all(|result| result.state == MigrationDomainState::Verified)); + assert_eq!(hash_source_roots(&roots), source_hash); + } + } + + #[test] + fn failed_and_cancelled_runs_leave_the_legacy_source_bytes_unchanged() { + let temp = test_tempdir("coordination-source-read-only"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let connection = materialize_coordination(&roots, false); + connection + .execute( + "UPDATE background_tasks SET parent_dialog_turn_id = 'missing-turn'", + [], + ) + .unwrap(); + drop(connection); + let source_hash = hash_source_roots(&roots); + let selection = session_selection(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let plan = engine + .plan(&source, selection.clone(), &CancellationToken::default()) + .unwrap(); + let failed = engine.execute(&plan, &CancellationToken::default(), &NoCrashInjection); + assert!(failed + .unwrap_err() + .to_string() + .contains("references missing Turn missing-turn")); + assert_eq!(hash_source_roots(&roots), source_hash); + + let cancellation = CancellationToken::default(); + cancellation.cancel(); + let cancelled = engine.plan(&source, selection, &cancellation); + assert!(matches!(cancelled, Err(LegacyMigrationError::Cancelled))); + assert_eq!(hash_source_roots(&roots), source_hash); + } + + fn session_selection() -> MigrationSelection { + MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::WorkspacesSessionsAndTasks]), + } + } + + fn fixture_roots(root: &Path) -> MigrationRoots { + let legacy_user_root = root.join("legacy-user"); + MigrationRoots { + legacy_skills_root: legacy_user_root.join("skills"), + legacy_user_root, + legacy_home_root: root.join("legacy-home"), + legacy_ssh_root: root.join("legacy-ssh"), + target_user_root: root.join("target-user"), + target_home_root: root.join("target-home"), + target_skills_root: root.join("target-skills"), + target_ssh_root: root.join("target-ssh"), + } + } + + fn copy_fixture(roots: &MigrationRoots) { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../services/legacy-migration/tests/fixtures/v0.2.19"); + copy_directory(&fixture.join("user-root"), &roots.legacy_user_root).unwrap(); + copy_directory(&fixture.join("home"), &roots.legacy_home_root).unwrap(); + copy_directory(&fixture.join("ssh"), &roots.legacy_ssh_root).unwrap(); + } + + fn materialize_coordination(roots: &MigrationRoots, with_wal_row: bool) -> Connection { + let sql_path = roots + .legacy_user_root + .join("data/agent-runtime/coordination.sql"); + let database_path = source_coordination_path(roots); + let connection = Connection::open(&database_path).unwrap(); + connection + .execute_batch(&fs::read_to_string(sql_path).unwrap()) + .unwrap(); + if with_wal_row { + connection + .pragma_update(None, "journal_mode", "WAL") + .unwrap(); + connection + .execute( + "INSERT INTO coordination_sessions (parent_session_id, next_auto_agent_seq, updated_at_ms) VALUES ('session-child-1', 1, 2)", + [], + ) + .unwrap(); + assert!(PathBuf::from(format!("{}-wal", database_path.display())).exists()); + } + connection + } + + fn copy_directory(source: &Path, target: &Path) -> std::io::Result<()> { + fs::create_dir_all(target)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_directory(&source_path, &target_path)?; + } else { + fs::copy(source_path, target_path)?; + } + } + Ok(()) + } + + fn hash_source_roots(roots: &MigrationRoots) -> String { + let mut entries = Vec::new(); + for root in [ + &roots.legacy_user_root, + &roots.legacy_home_root, + &roots.legacy_ssh_root, + ] { + collect_source_files(root, root, &mut entries); + } + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let mut hasher = Sha256::new(); + for (path, bytes) in entries { + hasher.update(path.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update([0]); + hasher.update(bytes); + hasher.update([0]); + } + hex::encode(hasher.finalize()) + } + + fn collect_source_files(root: &Path, path: &Path, entries: &mut Vec<(PathBuf, Vec)>) { + if path.is_file() { + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with("-shm")) + { + return; + } + let mut bytes = Vec::new(); + fs::File::open(path) + .unwrap() + .read_to_end(&mut bytes) + .unwrap(); + entries.push((path.strip_prefix(root).unwrap().to_path_buf(), bytes)); + return; + } + let mut children = fs::read_dir(path) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + children.sort(); + for child in children { + collect_source_files(root, &child, entries); + } + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("E:/tmp")); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix(&format!("openbitfun-migration-{label}-")) + .tempdir_in(root) + .unwrap() + } +} diff --git a/src/crates/assembly/core/src/legacy_migration/common.rs b/src/crates/assembly/core/src/legacy_migration/common.rs new file mode 100644 index 0000000000..e0357e47eb --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/common.rs @@ -0,0 +1,124 @@ +use openbitfun_legacy_migration::{ + atomic_write_bytes, LegacyMigrationError, LegacyMigrationResult, +}; +use serde::de::DeserializeOwned; +use std::fs; +use std::path::{Path, PathBuf}; + +pub(crate) const MAX_JSON_BYTES: u64 = 16 * 1024 * 1024; + +pub(crate) fn read_bounded_json( + root: &Path, + path: &Path, +) -> LegacyMigrationResult { + validate_regular_file(root, path)?; + let metadata = fs::metadata(path).map_err(|error| io_error(path, error))?; + if metadata.len() > MAX_JSON_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "JSON file exceeds {} bytes: {}", + MAX_JSON_BYTES, + relative_display(root, path) + ))); + } + let bytes = fs::read(path).map_err(|error| io_error(path, error))?; + serde_json::from_slice(&bytes).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "invalid JSON at {}: {error}", + relative_display(root, path) + )) + }) +} + +pub(crate) fn read_optional_bounded_json( + root: &Path, + path: &Path, +) -> LegacyMigrationResult> { + match fs::symlink_metadata(path) { + Ok(_) => read_bounded_json(root, path).map(Some), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(io_error(path, error)), + } +} + +pub(crate) fn validate_regular_file(root: &Path, path: &Path) -> LegacyMigrationResult<()> { + let metadata = fs::symlink_metadata(path).map_err(|error| io_error(path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path.to_path_buf())); + } + if !metadata.is_file() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "expected a regular file at {}", + relative_display(root, path) + ))); + } + let canonical_root = fs::canonicalize(root).map_err(|error| io_error(root, error))?; + let canonical_path = fs::canonicalize(path).map_err(|error| io_error(path, error))?; + if !canonical_path.starts_with(&canonical_root) { + return Err(LegacyMigrationError::PathEscape(path.to_path_buf())); + } + Ok(()) +} + +#[cfg(windows)] +fn is_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +pub(crate) fn backup_file_once(target: &Path, backup: &Path) -> LegacyMigrationResult<()> { + if backup.exists() || !target.exists() { + return Ok(()); + } + let bytes = fs::read(target).map_err(|error| io_error(target, error))?; + atomic_write_bytes(backup, &bytes) +} + +pub(crate) fn restore_unverified_file( + target: &Path, + backup: &Path, + target_existed: bool, +) -> LegacyMigrationResult<()> { + if backup.exists() { + let bytes = fs::read(backup).map_err(|error| io_error(backup, error))?; + return atomic_write_bytes(target, &bytes); + } + if !target_existed { + match fs::remove_file(target) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error(target, error)), + } + } + Ok(()) +} + +pub(crate) fn stage_domain_dir( + context: &openbitfun_legacy_migration::DomainContext<'_>, + name: &str, +) -> PathBuf { + context.layout.stage_root().join(name) +} + +pub(crate) fn backup_domain_dir( + context: &openbitfun_legacy_migration::DomainContext<'_>, + name: &str, +) -> PathBuf { + context.layout.backup_root().join(name) +} + +pub(crate) fn relative_display(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +pub(crate) fn io_error(path: &Path, error: std::io::Error) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!("I/O failed at {}: {error}", path.display())) +} diff --git a/src/crates/assembly/core/src/legacy_migration/extensions.rs b/src/crates/assembly/core/src/legacy_migration/extensions.rs new file mode 100644 index 0000000000..c9f18d4eee --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/extensions.rs @@ -0,0 +1,1719 @@ +use super::common::{read_bounded_json, stage_domain_dir}; +use openbitfun_agent_runtime::custom_agent::{ + custom_agent_read_markdown_str, custom_agent_save_markdown_file, CustomAgentDefinition, + CustomAgentLevel, +}; +use openbitfun_agent_runtime::skills::{SkillData, SkillLocation, OPENBITFUN_SYSTEM_SKILL_DIR}; +use openbitfun_legacy_migration::{ + atomic_write_bytes, atomic_write_json, DomainContext, DomainScan, LegacyDomainAdapter, + LegacyMigrationError, LegacyMigrationResult, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + ConflictResolution, FindingSeverity, MigrationConflict, MigrationDiagnostic, MigrationDomainId, + MigrationDomainResult, MigrationDomainState, ScanFinding, +}; +use openbitfun_product_domains::miniapp::builtin::{BUILTIN_APPS, BUILTIN_INSTALL_MARKER}; +use openbitfun_product_domains::miniapp::storage::{ + build_import_bundle_plan, MiniAppImportBundleWriteRequest, MiniAppStorageLayout, COMPILED_HTML, + ESM_DEPS_JSON, INDEX_HTML, META_JSON, PACKAGE_JSON, REQUIRED_SOURCE_FILES, SOURCE_DIR, + STORAGE_JSON, STYLE_CSS, UI_JS, WORKER_JS, +}; +use openbitfun_product_domains::miniapp::types::MiniAppMeta; +use openbitfun_services_integrations::miniapp::storage::MiniAppStorage; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const MAX_FILES_PER_EXTENSION: usize = 512; +const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +const MAX_EXTENSION_BYTES: u64 = 64 * 1024 * 1024; +const SKILL_ALLOWED_DIRECTORIES: &[&str] = + &["agents", "examples", "resources", "scripts", "templates"]; +const MINIAPP_ALLOWED_FILES: &[&str] = &[ + META_JSON, + INDEX_HTML, + STYLE_CSS, + UI_JS, + WORKER_JS, + ESM_DEPS_JSON, + PACKAGE_JSON, + STORAGE_JSON, + BUILTIN_INSTALL_MARKER, +]; + +pub(crate) struct SkillsAdapter; +pub(crate) struct MiniappsAdapter; +pub(crate) struct AgentsAdapter; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ImportAction { + Import, + Remap, + Duplicate, + BuiltinStorage, + TargetWins, + Skip, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ImportEntry { + source_id: String, + target_id: String, + action: ImportAction, + content_hash: String, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct ImportManifest { + entries: Vec, + skipped_paths: Vec, +} + +#[derive(Debug)] +struct PlannedTree { + source_id: String, + target_id: String, + action: ImportAction, + content_hash: String, + source_path: PathBuf, + files: Vec, + skipped_paths: Vec, +} + +#[derive(Debug)] +struct PlannedAgent { + source_id: String, + target_id: String, + action: ImportAction, + content_hash: String, + source_path: PathBuf, + definition: CustomAgentDefinition, +} + +impl LegacyDomainAdapter for SkillsAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::Skills + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let planned = plan_skills(roots)?; + Ok(scan_from_trees( + self.domain(), + "legacy_skills_supported", + "agent-skill.v1", + "openbitfun.agent-skill.current", + &planned, + )) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let planned = plan_skills(context.roots)?; + let output = stage_domain_dir(context, "skills").join("output"); + let mut manifest = ImportManifest::default(); + for item in &planned { + if matches!(item.action, ImportAction::Import | ImportAction::Remap) { + copy_declared_tree( + &item.source_path, + &output.join(&item.target_id), + &item.files, + )?; + } + manifest.entries.push(import_entry(item)); + manifest.skipped_paths.extend(item.skipped_paths.clone()); + } + atomic_write_json(&skills_manifest_path(context), &manifest)?; + result_from_manifest(self.domain(), &manifest) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "skills")?; + for entry in imported_entries(&manifest) { + let root = stage_domain_dir(context, "skills") + .join("output") + .join(&entry.target_id); + let skill_path = root.join("SKILL.md"); + let content = + fs::read_to_string(&skill_path).map_err(|error| io(&skill_path, error))?; + SkillData::from_markdown( + skill_path.to_string_lossy().to_string(), + &content, + SkillLocation::User, + false, + ) + .map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "staged Skill {} failed current owner parsing: {error}", + entry.target_id + )) + })?; + require_hash(&root, &entry.content_hash)?; + } + Ok(()) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "skills")?; + let target_root = &context.roots.target_skills_root; + for entry in imported_entries(&manifest) { + let staged = stage_domain_dir(context, "skills") + .join("output") + .join(&entry.target_id); + install_directory_idempotent( + &staged, + &target_root.join(&entry.target_id), + &entry.content_hash, + &context.plan.run_id, + )?; + } + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "skills")?; + for entry in imported_entries(&manifest) { + let root = context.roots.target_skills_root.join(&entry.target_id); + require_hash(&root, &entry.content_hash)?; + let path = root.join("SKILL.md"); + let content = fs::read_to_string(&path).map_err(|error| io(&path, error))?; + SkillData::from_markdown( + path.to_string_lossy().to_string(), + &content, + SkillLocation::User, + false, + ) + .map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "committed Skill {} failed current owner parsing: {error}", + entry.target_id + )) + })?; + } + Ok(()) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + rollback_imported_directories(context, "skills", &context.roots.target_skills_root) + } +} + +impl LegacyDomainAdapter for MiniappsAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::Miniapps + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let planned = plan_miniapps(roots)?; + Ok(scan_from_trees( + self.domain(), + "legacy_miniapps_supported", + "miniapp.flat.v1", + "openbitfun.miniapp.current", + &planned, + )) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let planned = plan_miniapps(context.roots)?; + let domain_root = stage_domain_dir(context, "miniapps"); + let import_inputs = domain_root.join("import-inputs"); + let output = domain_root.join("output"); + let mut manifest = ImportManifest::default(); + for item in &planned { + match item.action { + ImportAction::Import | ImportAction::Remap => { + let input = import_inputs.join(&item.target_id); + prepare_miniapp_import_input(item, &input)?; + let meta = fs::read_to_string(input.join(META_JSON)) + .map_err(|error| io(&input.join(META_JSON), error))?; + let plan = + build_import_bundle_plan(&item.target_id, &meta, 0).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "legacy MiniApp {} failed current owner conversion: {error}", + item.source_id + )) + })?; + MiniAppStorage::new(output.clone()) + .write_import_bundle_offline(MiniAppImportBundleWriteRequest { + source_path: input, + app_id: item.target_id.clone(), + meta_json: plan.meta_json, + esm_dependencies_json: plan.esm_dependencies_json, + package_json: plan.package_json, + storage_json: plan.storage_json, + compiled_html: plan.compiled_html, + }) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + } + ImportAction::BuiltinStorage => { + let source = item.source_path.join(STORAGE_JSON); + let value: serde_json::Value = read_bounded_json(&item.source_path, &source)?; + atomic_write_json( + &domain_root + .join("builtin-storage") + .join(&item.target_id) + .join(STORAGE_JSON), + &value, + )?; + } + ImportAction::Duplicate | ImportAction::TargetWins | ImportAction::Skip => {} + } + manifest.entries.push(import_entry(item)); + manifest.skipped_paths.extend(item.skipped_paths.clone()); + } + atomic_write_json(&miniapps_manifest_path(context), &manifest)?; + result_from_manifest(self.domain(), &manifest) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "miniapps")?; + let storage = MiniAppStorage::new(PathBuf::new()); + for entry in imported_entries(&manifest) { + let root = stage_domain_dir(context, "miniapps") + .join("output") + .join(&entry.target_id); + let meta = storage + .read_import_meta_json_offline(&root) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + let parsed: MiniAppMeta = serde_json::from_str(&meta).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "staged MiniApp {} failed current owner parsing: {error}", + entry.target_id + )) + })?; + if parsed.id != entry.target_id { + return Err(LegacyMigrationError::InvalidRequest(format!( + "staged MiniApp id mismatch: expected {}, found {}", + entry.target_id, parsed.id + ))); + } + } + for entry in manifest + .entries + .iter() + .filter(|entry| entry.action == ImportAction::BuiltinStorage) + { + let path = stage_domain_dir(context, "miniapps") + .join("builtin-storage") + .join(&entry.target_id) + .join(STORAGE_JSON); + let _: serde_json::Value = read_bounded_json(&context.layout.stage_root(), &path)?; + } + Ok(()) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "miniapps")?; + let target_root = target_miniapps_root(context.roots); + for entry in imported_entries(&manifest) { + let staged = stage_domain_dir(context, "miniapps") + .join("output") + .join(&entry.target_id); + let staged_hash = hash_declared_current_miniapp(&staged)?; + install_directory_idempotent( + &staged, + &target_root.join(&entry.target_id), + &staged_hash, + &context.plan.run_id, + )?; + } + for entry in manifest + .entries + .iter() + .filter(|entry| entry.action == ImportAction::BuiltinStorage) + { + let staged = stage_domain_dir(context, "miniapps") + .join("builtin-storage") + .join(&entry.target_id) + .join(STORAGE_JSON); + let target = target_root.join(&entry.target_id).join(STORAGE_JSON); + if target.exists() { + if hash_file(&target)? != hash_file(&staged)? { + return Err(LegacyMigrationError::InvalidRequest(format!( + "target MiniApp storage changed after planning: {}", + target.display() + ))); + } + } else { + let bytes = fs::read(&staged).map_err(|error| io(&staged, error))?; + atomic_write_bytes(&target, &bytes)?; + } + } + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "miniapps")?; + let target_root = target_miniapps_root(context.roots); + let storage = MiniAppStorage::new(PathBuf::new()); + for entry in imported_entries(&manifest) { + let root = target_root.join(&entry.target_id); + let meta = storage + .read_import_meta_json_offline(&root) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + let parsed: MiniAppMeta = serde_json::from_str(&meta).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "committed MiniApp {} failed current owner parsing: {error}", + entry.target_id + )) + })?; + if parsed.id != entry.target_id { + return Err(LegacyMigrationError::InvalidRequest(format!( + "committed MiniApp id mismatch for {}", + entry.target_id + ))); + } + } + for entry in manifest + .entries + .iter() + .filter(|entry| entry.action == ImportAction::BuiltinStorage) + { + let path = target_root.join(&entry.target_id).join(STORAGE_JSON); + let _: serde_json::Value = read_bounded_json(&target_root, &path)?; + } + Ok(()) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + rollback_imported_directories(context, "miniapps", &target_miniapps_root(context.roots))?; + rollback_builtin_storage(context) + } +} + +impl LegacyDomainAdapter for AgentsAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::Agents + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let planned = plan_agents(roots)?; + let conflicts = planned + .iter() + .filter_map(|item| { + conflict_for_action(self.domain(), &item.source_id, &item.target_id, item.action) + }) + .collect(); + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: "legacy_agents_supported".to_string(), + severity: FindingSeverity::Info, + entity_count: planned.len() as u64, + logical_bytes: planned + .iter() + .map(|item| fs::metadata(&item.source_path).map_or(0, |meta| meta.len())) + .sum(), + source_schema: Some("custom-agent.v1".to_string()), + migratable: true, + detail: + "Legacy user Agent definitions are parsed by the current owner before import." + .to_string(), + }, + conflicts, + target_schema: Some("openbitfun.custom-agent.current".to_string()), + dependencies: vec![MigrationDomainId::Skills, MigrationDomainId::Miniapps], + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let planned = plan_agents(context.roots)?; + let output = stage_domain_dir(context, "agents").join("output"); + let mut manifest = ImportManifest::default(); + for item in planned { + if matches!(item.action, ImportAction::Import | ImportAction::Remap) { + let path = output.join(format!("{}.md", safe_component(&item.target_id))); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| io(parent, error))?; + } + custom_agent_save_markdown_file(&path, &item.definition).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "failed to stage Agent {} through the current owner: {error}", + item.target_id + )) + })?; + let content = fs::read(&path).map_err(|error| io(&path, error))?; + manifest.entries.push(ImportEntry { + source_id: item.source_id, + target_id: item.target_id, + action: item.action, + content_hash: hash_bytes(&content), + }); + } else { + manifest.entries.push(ImportEntry { + source_id: item.source_id, + target_id: item.target_id, + action: item.action, + content_hash: item.content_hash, + }); + } + } + atomic_write_json(&agents_manifest_path(context), &manifest)?; + result_from_manifest(self.domain(), &manifest) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + validate_agent_manifest(context, true) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "agents")?; + let target_root = context.roots.target_user_root.join("agents"); + for entry in imported_entries(&manifest) { + let name = format!("{}.md", safe_component(&entry.target_id)); + let staged = stage_domain_dir(context, "agents") + .join("output") + .join(&name); + let target = target_root.join(&name); + install_file_idempotent(&staged, &target, &entry.content_hash)?; + } + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + validate_agent_manifest(context, false) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "agents")?; + let target_root = context.roots.target_user_root.join("agents"); + for entry in imported_entries(&manifest) { + let target = target_root.join(format!("{}.md", safe_component(&entry.target_id))); + if target.exists() && hash_file(&target)? == entry.content_hash { + fs::remove_file(&target).map_err(|error| io(&target, error))?; + } + } + Ok(()) + } +} + +fn plan_skills(roots: &MigrationRoots) -> LegacyMigrationResult> { + let source_root = legacy_skills_root(roots); + let target_root = &roots.target_skills_root; + let mut planned = Vec::new(); + for source in direct_child_directories(&source_root)? { + let source_id = file_name(&source)?; + if source_id == OPENBITFUN_SYSTEM_SKILL_DIR { + continue; + } + let skill_file = source.join("SKILL.md"); + let content = fs::read_to_string(&skill_file).map_err(|error| io(&skill_file, error))?; + SkillData::from_markdown( + skill_file.to_string_lossy().to_string(), + &content, + SkillLocation::User, + false, + ) + .map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "legacy Skill {source_id} failed current owner parsing: {error}" + )) + })?; + let (files, skipped_paths) = declared_skill_files(&source)?; + let content_hash = hash_file_set(&source, &files)?; + let target = target_root.join(&source_id); + let (target_id, action) = resolve_directory_conflict(&source_id, &content_hash, &target)?; + planned.push(PlannedTree { + source_id, + target_id, + action, + content_hash, + source_path: source, + files, + skipped_paths, + }); + } + Ok(planned) +} + +fn plan_miniapps(roots: &MigrationRoots) -> LegacyMigrationResult> { + let source_root = roots.legacy_user_root.join("data").join("miniapps"); + let target_root = target_miniapps_root(roots); + let builtin_ids = BUILTIN_APPS + .iter() + .map(|app| app.id) + .collect::>(); + let mut planned = Vec::new(); + for source in direct_child_directories(&source_root)? { + let directory_id = file_name(&source)?; + let meta_path = source.join(META_JSON); + let raw_meta: serde_json::Value = read_bounded_json(&source, &meta_path)?; + let declared_id = raw_meta + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let source_id = if declared_id.trim().is_empty() { + directory_id.clone() + } else { + declared_id.to_string() + }; + let current_builtin_id = if builtin_ids.contains(source_id.as_str()) { + Some(source_id.clone()) + } else if builtin_ids.contains(directory_id.as_str()) { + Some(directory_id.clone()) + } else { + None + }; + let is_builtin = + current_builtin_id.is_some() || source.join(BUILTIN_INSTALL_MARKER).exists(); + let (files, skipped_paths) = declared_flat_files(&source, MINIAPP_ALLOWED_FILES)?; + let content_hash = hash_file_set(&source, &files)?; + if is_builtin { + let target_id = current_builtin_id + .clone() + .unwrap_or_else(|| remapped_id(&source_id, &content_hash)); + let target_storage = target_root.join(&target_id).join(STORAGE_JSON); + planned.push(PlannedTree { + source_id, + target_id, + action: if current_builtin_id.is_none() || !source.join(STORAGE_JSON).exists() { + ImportAction::Skip + } else if target_storage.exists() { + ImportAction::TargetWins + } else { + ImportAction::BuiltinStorage + }, + content_hash, + source_path: source, + files, + skipped_paths, + }); + continue; + } + build_import_bundle_plan( + &source_id, + &fs::read_to_string(&meta_path).map_err(|error| io(&meta_path, error))?, + 0, + ) + .map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "legacy MiniApp {source_id} failed current owner conversion: {error}" + )) + })?; + let (target_id, action) = if is_safe_component(&source_id) { + resolve_miniapp_conflict(&source, &source_id, &content_hash, &target_root)? + } else { + resolve_remapped_miniapp_conflict(&source, &source_id, &content_hash, &target_root)? + }; + planned.push(PlannedTree { + source_id, + target_id, + action, + content_hash, + source_path: source, + files, + skipped_paths, + }); + } + Ok(planned) +} + +fn plan_agents(roots: &MigrationRoots) -> LegacyMigrationResult> { + let source_root = roots.legacy_user_root.join("agents"); + let target_root = roots.target_user_root.join("agents"); + let mut target_by_id = BTreeMap::new(); + for path in direct_markdown_files(&target_root)? { + let content = fs::read_to_string(&path).map_err(|error| io(&path, error))?; + if let Ok(parsed) = custom_agent_read_markdown_str(&content, CustomAgentLevel::User) { + target_by_id.insert(parsed.definition.id.to_ascii_lowercase(), parsed.definition); + } + } + let mut planned = Vec::new(); + for source_path in direct_markdown_files(&source_root)? { + let content = fs::read_to_string(&source_path).map_err(|error| io(&source_path, error))?; + let parsed = + custom_agent_read_markdown_str(&content, CustomAgentLevel::User).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "legacy Agent {} failed current owner parsing: {error}", + source_path.display() + )) + })?; + let source_id = parsed.definition.id.clone(); + let content_hash = hash_bytes(content.as_bytes()); + let (target_id, action, mut definition) = + match target_by_id.get(&source_id.to_ascii_lowercase()) { + Some(existing) if existing == &parsed.definition => ( + source_id.clone(), + ImportAction::Duplicate, + parsed.definition, + ), + Some(_) => { + let target_id = remapped_id(&source_id, &content_hash); + let mut definition = parsed.definition; + definition.id = target_id.clone(); + definition.name = format!("{} (from legacy product)", definition.name); + (target_id, ImportAction::Remap, definition) + } + None => (source_id.clone(), ImportAction::Import, parsed.definition), + }; + definition.level = CustomAgentLevel::User; + planned.push(PlannedAgent { + source_id, + target_id, + action, + content_hash, + source_path, + definition, + }); + } + Ok(planned) +} + +fn prepare_miniapp_import_input(item: &PlannedTree, input: &Path) -> LegacyMigrationResult<()> { + fs::create_dir_all(input.join(SOURCE_DIR)).map_err(|error| io(input, error))?; + let meta = fs::read(&item.source_path.join(META_JSON)) + .map_err(|error| io(&item.source_path.join(META_JSON), error))?; + atomic_write_bytes(&input.join(META_JSON), &meta)?; + for name in REQUIRED_SOURCE_FILES { + let source = item.source_path.join(name); + let target = input.join(SOURCE_DIR).join(name); + if source.exists() { + let bytes = fs::read(&source).map_err(|error| io(&source, error))?; + atomic_write_bytes(&target, &bytes)?; + } else { + atomic_write_bytes(&target, b"")?; + } + } + for name in [ESM_DEPS_JSON, PACKAGE_JSON, STORAGE_JSON] { + let source = item.source_path.join(name); + if source.exists() { + let bytes = fs::read(&source).map_err(|error| io(&source, error))?; + let target = if name == ESM_DEPS_JSON { + input.join(SOURCE_DIR).join(name) + } else { + input.join(name) + }; + atomic_write_bytes(&target, &bytes)?; + } + } + Ok(()) +} + +fn validate_agent_manifest(context: &DomainContext<'_>, staged: bool) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context, "agents")?; + let root = if staged { + stage_domain_dir(context, "agents").join("output") + } else { + context.roots.target_user_root.join("agents") + }; + for entry in imported_entries(&manifest) { + let path = root.join(format!("{}.md", safe_component(&entry.target_id))); + let content = fs::read_to_string(&path).map_err(|error| io(&path, error))?; + let parsed = + custom_agent_read_markdown_str(&content, CustomAgentLevel::User).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "Agent {} failed current owner parsing: {error}", + entry.target_id + )) + })?; + if parsed.definition.id != entry.target_id + || hash_bytes(content.as_bytes()) != entry.content_hash + { + return Err(LegacyMigrationError::InvalidRequest(format!( + "Agent {} does not match its staged identity", + entry.target_id + ))); + } + } + Ok(()) +} + +fn scan_from_trees( + domain: MigrationDomainId, + code: &str, + source_schema: &str, + target_schema: &str, + planned: &[PlannedTree], +) -> DomainScan { + DomainScan { + finding: ScanFinding { + domain, + code: code.to_string(), + severity: FindingSeverity::Info, + entity_count: planned.len() as u64, + logical_bytes: planned + .iter() + .map(|item| { + item.files + .iter() + .filter_map(|path| fs::metadata(path).ok()) + .map(|metadata| metadata.len()) + .sum::() + }) + .sum(), + source_schema: Some(source_schema.to_string()), + migratable: true, + detail: + "Only owner-approved files are staged; imported executable content remains inert." + .to_string(), + }, + conflicts: planned + .iter() + .filter_map(|item| { + conflict_for_action(domain, &item.source_id, &item.target_id, item.action) + }) + .collect(), + target_schema: Some(target_schema.to_string()), + dependencies: match domain { + MigrationDomainId::Miniapps => vec![MigrationDomainId::Skills], + _ => Vec::new(), + }, + } +} + +fn conflict_for_action( + domain: MigrationDomainId, + source_id: &str, + target_id: &str, + action: ImportAction, +) -> Option { + let (code, resolution) = match action { + ImportAction::Remap => ("extension_id_remapped", ConflictResolution::SourceRemapped), + ImportAction::Duplicate => ( + "extension_duplicate_skipped", + ConflictResolution::DuplicateSkipped, + ), + ImportAction::BuiltinStorage => { + ("builtin_code_excluded", ConflictResolution::SourceImported) + } + ImportAction::TargetWins => ( + "builtin_storage_target_preserved", + ConflictResolution::TargetWins, + ), + ImportAction::Skip => ("extension_item_skipped", ConflictResolution::ItemSkipped), + ImportAction::Import => return None, + }; + Some(MigrationConflict { + domain, + code: code.to_string(), + source_summary: format!("legacy extension id {source_id}"), + target_summary: format!("OpenBitFun extension id {target_id}"), + resolution, + }) +} + +fn result_from_manifest( + domain: MigrationDomainId, + manifest: &ImportManifest, +) -> LegacyMigrationResult { + let imported = manifest + .entries + .iter() + .filter(|entry| { + matches!( + entry.action, + ImportAction::Import | ImportAction::Remap | ImportAction::BuiltinStorage + ) + }) + .count() as u64; + let skipped = manifest + .entries + .iter() + .filter(|entry| { + matches!( + entry.action, + ImportAction::Duplicate | ImportAction::TargetWins | ImportAction::Skip + ) + }) + .count() as u64 + + manifest.skipped_paths.len() as u64; + let conflicts = manifest + .entries + .iter() + .filter(|entry| entry.action != ImportAction::Import) + .count() as u64; + let warnings = manifest + .skipped_paths + .iter() + .map(|path| MigrationDiagnostic { + code: "extension_path_not_declared".to_string(), + severity: FindingSeverity::Warning, + domain: Some(domain), + relative_path: Some(path.clone()), + message: "The path is outside the current owner's import contract.".to_string(), + action: Some("Review the source extension manually.".to_string()), + }) + .collect(); + Ok(MigrationDomainResult { + domain, + state: MigrationDomainState::Staged, + imported, + skipped, + conflicts, + warnings, + ..MigrationDomainResult::default() + }) +} + +fn import_entry(item: &PlannedTree) -> ImportEntry { + ImportEntry { + source_id: item.source_id.clone(), + target_id: item.target_id.clone(), + action: item.action, + content_hash: item.content_hash.clone(), + } +} + +fn imported_entries(manifest: &ImportManifest) -> impl Iterator { + manifest + .entries + .iter() + .filter(|entry| matches!(entry.action, ImportAction::Import | ImportAction::Remap)) +} + +fn read_manifest(context: &DomainContext<'_>, name: &str) -> LegacyMigrationResult { + read_bounded_json( + &context.layout.stage_root(), + &stage_domain_dir(context, name).join("manifest.json"), + ) +} + +fn skills_manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "skills").join("manifest.json") +} + +fn miniapps_manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "miniapps").join("manifest.json") +} + +fn agents_manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "agents").join("manifest.json") +} + +fn legacy_skills_root(roots: &MigrationRoots) -> PathBuf { + if roots.legacy_skills_root.exists() { + roots.legacy_skills_root.clone() + } else { + roots.legacy_user_root.join("skills") + } +} + +fn target_miniapps_root(roots: &MigrationRoots) -> PathBuf { + roots.target_user_root.join("data").join("miniapps") +} + +fn direct_child_directories(root: &Path) -> LegacyMigrationResult> { + if !root.exists() { + return Ok(Vec::new()); + } + reject_link(root)?; + let mut output = Vec::new(); + for entry in fs::read_dir(root).map_err(|error| io(root, error))? { + let entry = entry.map_err(|error| io(root, error))?; + let path = entry.path(); + reject_link(&path)?; + if entry + .file_type() + .map_err(|error| io(&path, error))? + .is_dir() + { + output.push(path); + } + } + output.sort(); + Ok(output) +} + +fn direct_markdown_files(root: &Path) -> LegacyMigrationResult> { + if !root.exists() { + return Ok(Vec::new()); + } + reject_link(root)?; + let mut output = Vec::new(); + for entry in fs::read_dir(root).map_err(|error| io(root, error))? { + let entry = entry.map_err(|error| io(root, error))?; + let path = entry.path(); + reject_link(&path)?; + if entry + .file_type() + .map_err(|error| io(&path, error))? + .is_file() + && path.extension().and_then(|value| value.to_str()) == Some("md") + { + output.push(path); + } + } + output.sort(); + Ok(output) +} + +fn declared_skill_files(root: &Path) -> LegacyMigrationResult<(Vec, Vec)> { + let mut files = Vec::new(); + let mut skipped = Vec::new(); + for entry in fs::read_dir(root).map_err(|error| io(root, error))? { + let entry = entry.map_err(|error| io(root, error))?; + let path = entry.path(); + reject_link(&path)?; + let name = entry.file_name().to_string_lossy().to_string(); + let kind = entry.file_type().map_err(|error| io(&path, error))?; + if kind.is_file() && name == "SKILL.md" { + files.push(path); + } else if kind.is_dir() && SKILL_ALLOWED_DIRECTORIES.contains(&name.as_str()) { + collect_regular_files(root, &path, &mut files)?; + } else { + skipped.push(name); + } + } + enforce_tree_limits(root, &files)?; + if !files.iter().any(|path| path == &root.join("SKILL.md")) { + return Err(LegacyMigrationError::InvalidRequest(format!( + "Skill {} does not contain SKILL.md", + root.display() + ))); + } + Ok((files, skipped)) +} + +fn declared_flat_files( + root: &Path, + allowed: &[&str], +) -> LegacyMigrationResult<(Vec, Vec)> { + let mut files = Vec::new(); + let mut skipped = Vec::new(); + for entry in fs::read_dir(root).map_err(|error| io(root, error))? { + let entry = entry.map_err(|error| io(root, error))?; + let path = entry.path(); + reject_link(&path)?; + let name = entry.file_name().to_string_lossy().to_string(); + if entry + .file_type() + .map_err(|error| io(&path, error))? + .is_file() + && allowed.contains(&name.as_str()) + { + files.push(path); + } else { + skipped.push(name); + } + } + enforce_tree_limits(root, &files)?; + Ok((files, skipped)) +} + +fn collect_regular_files( + declared_root: &Path, + current: &Path, + output: &mut Vec, +) -> LegacyMigrationResult<()> { + reject_link(current)?; + for entry in fs::read_dir(current).map_err(|error| io(current, error))? { + let entry = entry.map_err(|error| io(current, error))?; + let path = entry.path(); + reject_link(&path)?; + let kind = entry.file_type().map_err(|error| io(&path, error))?; + if kind.is_dir() { + collect_regular_files(declared_root, &path, output)?; + } else if kind.is_file() { + output.push(path); + } + if output.len() > MAX_FILES_PER_EXTENSION { + return Err(LegacyMigrationError::ResourceLimit(format!( + "extension exceeds {MAX_FILES_PER_EXTENSION} files: {}", + declared_root.display() + ))); + } + } + Ok(()) +} + +fn enforce_tree_limits(root: &Path, files: &[PathBuf]) -> LegacyMigrationResult<()> { + if files.len() > MAX_FILES_PER_EXTENSION { + return Err(LegacyMigrationError::ResourceLimit(format!( + "extension exceeds {MAX_FILES_PER_EXTENSION} files: {}", + root.display() + ))); + } + let mut total = 0u64; + for path in files { + let bytes = fs::metadata(path).map_err(|error| io(path, error))?.len(); + if bytes > MAX_FILE_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "extension file exceeds {MAX_FILE_BYTES} bytes: {}", + path.display() + ))); + } + total = total.saturating_add(bytes); + } + if total > MAX_EXTENSION_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "extension exceeds {MAX_EXTENSION_BYTES} bytes: {}", + root.display() + ))); + } + Ok(()) +} + +fn copy_declared_tree( + source_root: &Path, + target_root: &Path, + files: &[PathBuf], +) -> LegacyMigrationResult<()> { + for source in files { + let relative = source + .strip_prefix(source_root) + .map_err(|_| LegacyMigrationError::PathEscape(source.to_path_buf()))?; + let target = target_root.join(relative); + let bytes = fs::read(source).map_err(|error| io(source, error))?; + atomic_write_bytes(&target, &bytes)?; + } + Ok(()) +} + +fn copy_directory(source: &Path, target: &Path) -> LegacyMigrationResult<()> { + let mut files = Vec::new(); + collect_regular_files(source, source, &mut files)?; + enforce_tree_limits(source, &files)?; + copy_declared_tree(source, target, &files) +} + +fn resolve_directory_conflict( + source_id: &str, + source_hash: &str, + target: &Path, +) -> LegacyMigrationResult<(String, ImportAction)> { + if !target.exists() { + return Ok((source_id.to_string(), ImportAction::Import)); + } + reject_link(target)?; + let target_hash = hash_tree(target)?; + if target_hash == source_hash { + Ok((source_id.to_string(), ImportAction::Duplicate)) + } else { + Ok((remapped_id(source_id, source_hash), ImportAction::Remap)) + } +} + +fn resolve_miniapp_conflict( + source: &Path, + source_id: &str, + source_hash: &str, + target_root: &Path, +) -> LegacyMigrationResult<(String, ImportAction)> { + let target = target_root.join(source_id); + if !target.exists() { + return Ok((source_id.to_string(), ImportAction::Import)); + } + reject_link(&target)?; + if current_miniapp_matches_legacy(source, &target, source_id)? { + return Ok((source_id.to_string(), ImportAction::Duplicate)); + } + let remapped = remapped_id(source_id, source_hash); + let remapped_target = target_root.join(&remapped); + if remapped_target.exists() { + reject_link(&remapped_target)?; + if current_miniapp_matches_legacy(source, &remapped_target, &remapped)? { + return Ok((remapped, ImportAction::Duplicate)); + } + return Err(LegacyMigrationError::InvalidRequest(format!( + "remapped MiniApp target already contains different data: {}", + remapped_target.display() + ))); + } + Ok((remapped, ImportAction::Remap)) +} + +fn resolve_remapped_miniapp_conflict( + source: &Path, + source_id: &str, + source_hash: &str, + target_root: &Path, +) -> LegacyMigrationResult<(String, ImportAction)> { + let remapped = remapped_id(source_id, source_hash); + let target = target_root.join(&remapped); + if !target.exists() { + return Ok((remapped, ImportAction::Remap)); + } + reject_link(&target)?; + if current_miniapp_matches_legacy(source, &target, &remapped)? { + Ok((remapped, ImportAction::Duplicate)) + } else { + Err(LegacyMigrationError::InvalidRequest(format!( + "remapped MiniApp target already contains different data: {}", + target.display() + ))) + } +} + +fn current_miniapp_matches_legacy( + source: &Path, + target: &Path, + target_id: &str, +) -> LegacyMigrationResult { + let source_meta = fs::read_to_string(source.join(META_JSON)) + .map_err(|error| io(&source.join(META_JSON), error))?; + let plan = build_import_bundle_plan(target_id, &source_meta, 0).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "legacy MiniApp {target_id} failed current owner conversion: {error}" + )) + })?; + let expected_meta: serde_json::Value = + serde_json::from_str(&plan.meta_json).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("invalid converted MiniApp meta: {error}")) + })?; + let actual_meta: serde_json::Value = match read_bounded_json(target, &target.join(META_JSON)) { + Ok(value) => value, + Err(_) => return Ok(false), + }; + if actual_meta != expected_meta { + return Ok(false); + } + for name in REQUIRED_SOURCE_FILES { + let source_path = source.join(name); + let expected = if source_path.exists() { + fs::read(&source_path).map_err(|error| io(&source_path, error))? + } else { + Vec::new() + }; + let target_path = target.join(SOURCE_DIR).join(name); + if fs::read(&target_path).ok().as_deref() != Some(expected.as_slice()) { + return Ok(false); + } + } + let expected_esm = optional_bytes_or( + &source.join(ESM_DEPS_JSON), + plan.esm_dependencies_json.as_bytes(), + )?; + if fs::read(target.join(SOURCE_DIR).join(ESM_DEPS_JSON)) + .ok() + .as_deref() + != Some(expected_esm.as_slice()) + { + return Ok(false); + } + for (name, fallback) in [ + (PACKAGE_JSON, plan.package_json.as_bytes()), + (STORAGE_JSON, plan.storage_json.as_bytes()), + ] { + let expected = optional_bytes_or(&source.join(name), fallback)?; + if fs::read(target.join(name)).ok().as_deref() != Some(expected.as_slice()) { + return Ok(false); + } + } + Ok(fs::read(target.join(COMPILED_HTML)).ok().as_deref() == Some(plan.compiled_html.as_bytes())) +} + +fn optional_bytes_or(path: &Path, fallback: &[u8]) -> LegacyMigrationResult> { + match fs::read(path) { + Ok(bytes) => Ok(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(fallback.to_vec()), + Err(error) => Err(io(path, error)), + } +} + +fn install_directory_idempotent( + staged: &Path, + target: &Path, + expected_hash: &str, + run_id: &str, +) -> LegacyMigrationResult<()> { + if target.exists() { + if hash_tree(target)? == expected_hash { + return Ok(()); + } + return Err(LegacyMigrationError::InvalidRequest(format!( + "target changed after planning: {}", + target.display() + ))); + } + let parent = target.parent().ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!("target has no parent: {}", target.display())) + })?; + fs::create_dir_all(parent).map_err(|error| io(parent, error))?; + let temp = parent.join(format!( + ".migration-{}-{}", + safe_component(run_id), + target.file_name().unwrap_or_default().to_string_lossy() + )); + if temp.exists() { + fs::remove_dir_all(&temp).map_err(|error| io(&temp, error))?; + } + copy_directory(staged, &temp)?; + fs::rename(&temp, target).map_err(|error| io(target, error)) +} + +fn install_file_idempotent( + staged: &Path, + target: &Path, + expected_hash: &str, +) -> LegacyMigrationResult<()> { + if target.exists() { + if hash_file(target)? == expected_hash { + return Ok(()); + } + return Err(LegacyMigrationError::InvalidRequest(format!( + "target changed after planning: {}", + target.display() + ))); + } + let bytes = fs::read(staged).map_err(|error| io(staged, error))?; + atomic_write_bytes(target, &bytes) +} + +fn rollback_imported_directories( + context: &DomainContext<'_>, + name: &str, + target_root: &Path, +) -> LegacyMigrationResult<()> { + let manifest_path = stage_domain_dir(context, name).join("manifest.json"); + if !manifest_path.exists() { + return Ok(()); + } + let manifest = read_manifest(context, name)?; + for entry in imported_entries(&manifest) { + let target = target_root.join(&entry.target_id); + if target.exists() { + let expected = if name == "miniapps" { + let staged = stage_domain_dir(context, name) + .join("output") + .join(&entry.target_id); + hash_declared_current_miniapp(&staged)? + } else { + entry.content_hash.clone() + }; + if hash_tree(&target)? == expected { + fs::remove_dir_all(&target).map_err(|error| io(&target, error))?; + } + } + } + Ok(()) +} + +fn rollback_builtin_storage(context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest_path = stage_domain_dir(context, "miniapps").join("manifest.json"); + if !manifest_path.exists() { + return Ok(()); + } + let manifest = read_manifest(context, "miniapps")?; + let target_root = target_miniapps_root(context.roots); + for entry in manifest + .entries + .iter() + .filter(|entry| entry.action == ImportAction::BuiltinStorage) + { + let staged = stage_domain_dir(context, "miniapps") + .join("builtin-storage") + .join(&entry.target_id) + .join(STORAGE_JSON); + let target = target_root.join(&entry.target_id).join(STORAGE_JSON); + remove_file_if_matches(&staged, &target)?; + } + Ok(()) +} + +fn remove_file_if_matches(staged: &Path, target: &Path) -> LegacyMigrationResult<()> { + if staged.exists() && target.exists() && hash_file(staged)? == hash_file(target)? { + fs::remove_file(target).map_err(|error| io(target, error))?; + } + Ok(()) +} + +fn require_hash(root: &Path, expected: &str) -> LegacyMigrationResult<()> { + let actual = hash_tree(root)?; + if actual != expected { + return Err(LegacyMigrationError::InvalidRequest(format!( + "staged extension hash mismatch at {}", + root.display() + ))); + } + Ok(()) +} + +fn hash_declared_current_miniapp(root: &Path) -> LegacyMigrationResult { + let layout = MiniAppStorageLayout::new(root.parent().unwrap_or(root), file_name(root)?); + let mut files = vec![ + layout.meta_path(), + layout.package_json_path(), + layout.storage_path(), + layout.compiled_path(), + ]; + files.extend( + REQUIRED_SOURCE_FILES + .iter() + .map(|name| layout.source_file_path(name)), + ); + files.push(layout.source_file_path(ESM_DEPS_JSON)); + let files = files + .into_iter() + .filter(|path| path.exists()) + .collect::>(); + hash_file_set(root, &files) +} + +fn hash_tree(root: &Path) -> LegacyMigrationResult { + let mut files = Vec::new(); + collect_regular_files(root, root, &mut files)?; + enforce_tree_limits(root, &files)?; + hash_file_set(root, &files) +} + +fn hash_file_set(root: &Path, files: &[PathBuf]) -> LegacyMigrationResult { + let mut relative = files + .iter() + .map(|path| { + path.strip_prefix(root) + .map(|relative| (relative.to_path_buf(), path.clone())) + .map_err(|_| LegacyMigrationError::PathEscape(path.clone())) + }) + .collect::>>()?; + relative.sort_by(|left, right| left.0.cmp(&right.0)); + let mut hasher = Sha256::new(); + for (relative, path) in relative { + hasher.update(relative.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update([0]); + hasher.update(fs::read(&path).map_err(|error| io(&path, error))?); + hasher.update([0]); + } + Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) +} + +fn hash_file(path: &Path) -> LegacyMigrationResult { + fs::read(path) + .map(|bytes| hash_bytes(&bytes)) + .map_err(|error| io(path, error)) +} + +fn hash_bytes(bytes: &[u8]) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(bytes))) +} + +fn remapped_id(source_id: &str, content_hash: &str) -> String { + let suffix = content_hash + .strip_prefix("sha256:") + .unwrap_or(content_hash) + .chars() + .take(8) + .collect::(); + let base = safe_component(source_id); + let base = if base.is_empty() { + "legacy-item".to_string() + } else { + base + }; + format!("{base}-from-legacy-{suffix}") +} + +fn is_safe_component(value: &str) -> bool { + if value.is_empty() || safe_component(value) != value { + return false; + } + let stem = value + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + !matches!( + stem.as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ) +} + +fn safe_component(value: &str) -> String { + let normalized = value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { + character + } else { + '-' + } + }) + .collect::(); + normalized.trim_matches(['.', '-']).to_string() +} + +fn file_name(path: &Path) -> LegacyMigrationResult { + path.file_name() + .and_then(|value| value.to_str()) + .map(str::to_string) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!( + "path has no valid UTF-8 file name: {}", + path.display() + )) + }) +} + +fn reject_link(path: &Path) -> LegacyMigrationResult<()> { + let metadata = fs::symlink_metadata(path).map_err(|error| io(path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path.to_path_buf())); + } + Ok(()) +} + +#[cfg(windows)] +fn is_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x0400 != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +fn io(path: &Path, error: std::io::Error) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!("I/O failed at {}: {error}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::legacy_migration::adapters_for_groups; + use openbitfun_legacy_migration::{ + probe_legacy_source, CancellationToken, MigrationEngine, NoCrashInjection, ProbeLimits, + }; + use openbitfun_product_domains::legacy_migration::{MigrationGroupId, MigrationSelection}; + use std::collections::BTreeSet; + + #[test] + fn extension_group_uses_owner_formats_and_excludes_builtins() { + let temp = test_tempdir("extensions"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let unknown = roots + .legacy_skills_root + .join("user-skill") + .join("undeclared.txt"); + atomic_write_bytes(&unknown, b"private fixture content that must stay excluded").unwrap(); + let source_hashes = [ + hash_tree(&roots.legacy_user_root).unwrap(), + hash_tree(&roots.legacy_home_root).unwrap(), + hash_tree(&roots.legacy_ssh_root).unwrap(), + ]; + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::AgentsSkillsAndMiniapps]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection.clone(), &CancellationToken::default()) + .unwrap(); + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + + assert!(roots + .target_skills_root + .join("user-skill/SKILL.md") + .exists()); + assert!(!roots + .target_skills_root + .join(OPENBITFUN_SYSTEM_SKILL_DIR) + .exists()); + assert!(!roots + .target_skills_root + .join("user-skill/undeclared.txt") + .exists()); + assert!(report + .domain_results + .iter() + .flat_map(|result| &result.warnings) + .any(|warning| warning.code == "extension_path_not_declared")); + assert!(!serde_json::to_string(&report) + .unwrap() + .contains("private fixture content")); + let custom = target_miniapps_root(&roots).join("custom-notes"); + assert!(custom.join("source/index.html").exists()); + assert!(custom.join(COMPILED_HTML).exists()); + assert!(!target_miniapps_root(&roots) + .join("builtin-gomoku/index.html") + .exists()); + assert!(target_miniapps_root(&roots) + .join("builtin-gomoku/storage.json") + .exists()); + let agent_path = roots.target_user_root.join("agents/researcher.md"); + let agent = custom_agent_read_markdown_str( + &fs::read_to_string(agent_path).unwrap(), + CustomAgentLevel::User, + ) + .unwrap(); + assert_eq!(agent.definition.id, "researcher"); + + let second_source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let second = engine + .plan(&second_source, selection, &CancellationToken::default()) + .unwrap(); + assert!(second + .conflicts + .iter() + .any(|conflict| conflict.resolution == ConflictResolution::TargetWins)); + engine + .execute(&second, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert_eq!( + direct_child_directories(&roots.target_skills_root) + .unwrap() + .len(), + 1 + ); + assert_eq!( + direct_markdown_files(&roots.target_user_root.join("agents")) + .unwrap() + .len(), + 1 + ); + assert_eq!( + [ + hash_tree(&roots.legacy_user_root).unwrap(), + hash_tree(&roots.legacy_home_root).unwrap(), + hash_tree(&roots.legacy_ssh_root).unwrap(), + ], + source_hashes + ); + } + + #[test] + fn conflicting_skill_is_remapped_without_overwriting_target() { + let temp = test_tempdir("skill-conflict"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let target = roots.target_skills_root.join("user-skill/SKILL.md"); + atomic_write_bytes( + &target, + b"---\nname: user-skill\ndescription: target\n---\n\nTarget body.\n", + ) + .unwrap(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::AgentsSkillsAndMiniapps]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + assert!(plan + .conflicts + .iter() + .any(|conflict| conflict.resolution == ConflictResolution::SourceRemapped)); + engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert!(fs::read_to_string(&target).unwrap().contains("Target body")); + assert_eq!( + direct_child_directories(&roots.target_skills_root) + .unwrap() + .len(), + 2 + ); + } + + #[test] + fn unsafe_miniapp_id_is_remapped_before_target_path_construction() { + let temp = test_tempdir("path"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let meta = roots + .legacy_user_root + .join("data/miniapps/custom-notes/meta.json"); + let mut meta_value: serde_json::Value = + serde_json::from_slice(&fs::read(&meta).unwrap()).unwrap(); + meta_value["id"] = serde_json::Value::String("../..".to_string()); + atomic_write_json(&meta, &meta_value).unwrap(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::AgentsSkillsAndMiniapps]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + assert!(plan.conflicts.iter().any(|conflict| { + conflict.domain == MigrationDomainId::Miniapps + && conflict.resolution == ConflictResolution::SourceRemapped + })); + engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert!(!roots.target_user_root.join(META_JSON).exists()); + assert!(!roots.target_user_root.join(SOURCE_DIR).exists()); + assert!(direct_child_directories(&target_miniapps_root(&roots)) + .unwrap() + .iter() + .any(|path| file_name(path) + .unwrap() + .starts_with("legacy-item-from-legacy-"))); + } + + #[test] + fn rollback_removes_only_matching_builtin_storage() { + let temp = test_tempdir("builtin-storage-rollback"); + let staged = temp.path().join("staged/storage.json"); + let target = temp.path().join("target/storage.json"); + atomic_write_bytes(&staged, br#"{"value":"legacy"}"#).unwrap(); + atomic_write_bytes(&target, br#"{"value":"legacy"}"#).unwrap(); + remove_file_if_matches(&staged, &target).unwrap(); + assert!(!target.exists()); + + atomic_write_bytes(&target, br#"{"value":"target"}"#).unwrap(); + remove_file_if_matches(&staged, &target).unwrap(); + assert_eq!(fs::read(&target).unwrap(), br#"{"value":"target"}"#); + } + + fn fixture_roots(root: &Path) -> MigrationRoots { + let legacy_user_root = root.join("legacy-user"); + MigrationRoots { + legacy_skills_root: legacy_user_root.join("skills"), + legacy_user_root, + legacy_home_root: root.join("legacy-home"), + legacy_ssh_root: root.join("legacy-ssh"), + target_user_root: root.join("target-user"), + target_home_root: root.join("target-home"), + target_skills_root: root.join("target-skills"), + target_ssh_root: root.join("target-ssh"), + } + } + + fn copy_fixture(roots: &MigrationRoots) { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../services/legacy-migration/tests/fixtures/v0.2.19"); + copy_directory(&fixture.join("user-root"), &roots.legacy_user_root).unwrap(); + copy_directory(&fixture.join("home"), &roots.legacy_home_root).unwrap(); + copy_directory(&fixture.join("ssh"), &roots.legacy_ssh_root).unwrap(); + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("E:/tmp")); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix(&format!("openbitfun-migration-{label}-")) + .tempdir_in(root) + .unwrap() + } +} diff --git a/src/crates/assembly/core/src/legacy_migration/memory.rs b/src/crates/assembly/core/src/legacy_migration/memory.rs new file mode 100644 index 0000000000..c5e7c3dba6 --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/memory.rs @@ -0,0 +1,2124 @@ +use super::common::{ + backup_domain_dir, io_error, read_bounded_json, read_optional_bounded_json, stage_domain_dir, + validate_regular_file, +}; +use openbitfun_legacy_migration::{ + atomic_write_bytes, atomic_write_json, snapshot_sqlite_read_only, validate_sqlite, + DomainContext, DomainScan, LegacyDomainAdapter, LegacyMigrationError, LegacyMigrationResult, + MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + ConflictResolution, FindingSeverity, MigrationConflict, MigrationDomainId, + MigrationDomainResult, MigrationDomainState, ScanFinding, +}; +use openbitfun_services_core::memory_store::{ + classify_memory_workspace_file, initialize_memory_schema, read_memory_store_snapshot, + upsert_memory_record, MemoryRecord, MemoryStoreSnapshot, MemoryWorkspaceFileKind, + MEMORY_STORE_SCHEMA, +}; +use rusqlite::{Connection, OpenFlags}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use uuid::Uuid; + +const STRUCTURED_MEMORY_RELATIVE_PATH: &str = "data/memories/memories.sqlite"; +const LEGACY_STRUCTURED_MEMORY_SCHEMA: &str = "bitfun.memory.stage1.v1"; +const FILE_MEMORY_SCHEMA: &str = "openbitfun.memory-files.v1"; +const MAX_MEMORY_FILE_COUNT: usize = 4_096; +const MAX_MEMORY_FILE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_MEMORY_TOTAL_BYTES: u64 = 512 * 1024 * 1024; + +pub(crate) struct StructuredMemoryAdapter; +pub(crate) struct FileMemoryAdapter; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StructuredMemoryManifest { + source_present: bool, + source_digest: Option, + target_existed: bool, + target_digest: Option, + merged_digest: Option, + imported: u64, + skipped: u64, + conflicts: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StructuredMemoryCommitReceipt { + target_existed: bool, + merged_digest: String, +} + +#[derive(Debug, Default)] +struct StructuredMergeOutcome { + imports: Vec, + duplicate: u64, + target_wins: u64, + conflicts: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum FileMemoryAction { + Import, + Duplicate, + TargetWins, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileMemoryManifestEntry { + source_relative: String, + target_relative: String, + action: FileMemoryAction, + expected_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileMemoryCommitReceipt { + target_relative: String, + expected_hash: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct FileMemoryManifest { + entries: Vec, + imported: u64, + skipped: u64, + conflicts: u64, +} + +struct PlannedMemoryFile { + source_relative: PathBuf, + target_relative: PathBuf, + source_path: PathBuf, + action: FileMemoryAction, + expected_hash: String, +} + +struct FileMemoryPlan { + files: Vec, + conflicts: Vec, + logical_bytes: u64, +} + +struct MemoryFileFact { + relative: PathBuf, + path: PathBuf, + hash: String, + bytes: u64, + kind: MemoryWorkspaceFileKind, +} + +impl LegacyDomainAdapter for StructuredMemoryAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::StructuredMemory + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let source_path = source_structured_memory_path(roots); + if !path_entry_exists(&source_path)? { + return Ok(empty_domain_scan( + self.domain(), + "legacy_structured_memory_absent", + LEGACY_STRUCTURED_MEMORY_SCHEMA, + MEMORY_STORE_SCHEMA, + "No legacy structured Memory database was found.", + )); + } + let source = read_consistent_scan_snapshot( + roots, + &roots.legacy_user_root, + &source_path, + "structured-memory-source", + MemoryDatabaseRole::LegacySource, + )?; + let target_path = target_structured_memory_path(roots); + let target = if path_entry_exists(&target_path)? { + read_consistent_scan_snapshot( + roots, + &roots.target_user_root, + &target_path, + "structured-memory-target", + MemoryDatabaseRole::CurrentTarget, + )? + } else { + MemoryStoreSnapshot::default() + }; + let outcome = preview_structured_merge(&source.records, &target.records); + let logical_bytes = sqlite_family_size(&source_path)?; + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: "legacy_structured_memory_supported".to_string(), + severity: if outcome.conflicts.is_empty() { + FindingSeverity::Info + } else { + FindingSeverity::Warning + }, + entity_count: source.records.len() as u64, + logical_bytes, + source_schema: Some(LEGACY_STRUCTURED_MEMORY_SCHEMA.to_string()), + migratable: true, + detail: format!( + "{} structured Memory records are readable by the current persistence owner", + source.records.len() + ), + }, + conflicts: outcome.conflicts, + target_schema: Some(MEMORY_STORE_SCHEMA.to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let domain_root = stage_domain_dir(context, "structured-memory"); + reset_directory(&domain_root)?; + let source_path = source_structured_memory_path(context.roots); + if !path_entry_exists(&source_path)? { + let manifest = StructuredMemoryManifest { + source_present: false, + source_digest: None, + target_existed: path_entry_exists(&target_structured_memory_path(context.roots))?, + target_digest: None, + merged_digest: None, + imported: 0, + skipped: 0, + conflicts: 0, + }; + atomic_write_json(&structured_manifest_path(context), &manifest)?; + return Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + ..MigrationDomainResult::default() + }); + } + + let staged_source = domain_root.join("source.sqlite"); + let staged_target = domain_root.join("target-before.sqlite"); + let staged_merged = domain_root.join("merged.sqlite"); + validate_sqlite_family(&context.roots.legacy_user_root, &source_path)?; + snapshot_sqlite_read_only(&source_path, &staged_source)?; + let source = read_memory_snapshot(&staged_source, MemoryDatabaseRole::LegacySource)?; + + let target_path = target_structured_memory_path(context.roots); + let target_existed = path_entry_exists(&target_path)?; + let (target, target_digest) = if target_existed { + validate_sqlite_family(&context.roots.target_user_root, &target_path)?; + snapshot_sqlite_read_only(&target_path, &staged_target)?; + let target = read_memory_snapshot(&staged_target, MemoryDatabaseRole::CurrentTarget)?; + snapshot_sqlite_read_only(&staged_target, &staged_merged)?; + let digest = memory_snapshot_digest(&target)?; + (target, Some(digest)) + } else { + let connection = Connection::open(&staged_merged) + .map_err(|error| db_error(&staged_merged, error))?; + initialize_memory_schema(&connection).map_err(owner_error)?; + (MemoryStoreSnapshot::default(), None) + }; + + let outcome = preview_structured_merge(&source.records, &target.records); + { + let mut connection = Connection::open(&staged_merged) + .map_err(|error| db_error(&staged_merged, error))?; + let transaction = connection + .transaction() + .map_err(|error| db_error(&staged_merged, error))?; + for record in &outcome.imports { + upsert_memory_record(&transaction, record, false).map_err(owner_error)?; + } + transaction + .commit() + .map_err(|error| db_error(&staged_merged, error))?; + } + finalize_sqlite_file(&staged_merged)?; + let merged = read_memory_snapshot(&staged_merged, MemoryDatabaseRole::StagedCurrent)?; + let manifest = StructuredMemoryManifest { + source_present: true, + source_digest: Some(memory_snapshot_digest(&source)?), + target_existed, + target_digest, + merged_digest: Some(memory_snapshot_digest(&merged)?), + imported: outcome.imports.len() as u64, + skipped: outcome.duplicate.saturating_add(outcome.target_wins), + conflicts: outcome.conflicts.len() as u64, + }; + atomic_write_json(&structured_manifest_path(context), &manifest)?; + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported: manifest.imported, + skipped: manifest.skipped, + conflicts: manifest.conflicts, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_structured_manifest(context)?; + if !manifest.source_present { + return Ok(()); + } + let domain_root = stage_domain_dir(context, "structured-memory"); + let source = read_memory_snapshot( + &domain_root.join("source.sqlite"), + MemoryDatabaseRole::LegacySource, + )?; + if Some(memory_snapshot_digest(&source)?) != manifest.source_digest { + return Err(LegacyMigrationError::InvalidRequest( + "staged structured Memory source differs from its manifest".to_string(), + )); + } + let merged = read_memory_snapshot( + &domain_root.join("merged.sqlite"), + MemoryDatabaseRole::StagedCurrent, + )?; + if Some(memory_snapshot_digest(&merged)?) != manifest.merged_digest { + return Err(LegacyMigrationError::InvalidRequest( + "staged structured Memory merge differs from its manifest".to_string(), + )); + } + Ok(()) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_structured_manifest(context)?; + if !manifest.source_present { + return Ok(()); + } + let expected = manifest.merged_digest.as_deref().ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "structured Memory manifest is missing its merged digest".to_string(), + ) + })?; + let target = target_structured_memory_path(context.roots); + let existing_receipt = read_structured_commit_receipt(context)?; + if path_entry_exists(&target)? + && existing_receipt.as_ref().is_some_and(|receipt| { + receipt.target_existed == manifest.target_existed + && receipt.merged_digest == expected + }) + { + validate_sqlite_family(&context.roots.target_user_root, &target)?; + if let Ok(current) = read_memory_snapshot(&target, MemoryDatabaseRole::CurrentTarget) { + if memory_snapshot_digest(¤t)? == expected { + finalize_sqlite_file(&target)?; + return Ok(()); + } + } + } + verify_structured_target_state(&target, &manifest)?; + atomic_write_json( + &structured_commit_receipt_path(context), + &StructuredMemoryCommitReceipt { + target_existed: manifest.target_existed, + merged_digest: expected.to_string(), + }, + )?; + let backup = backup_domain_dir(context, "structured-memory").join("memories.sqlite"); + if manifest.target_existed && !path_entry_exists(&backup)? { + snapshot_sqlite_read_only(&target, &backup)?; + } + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; + } + remove_sqlite_sidecars(&target)?; + let merged = stage_domain_dir(context, "structured-memory").join("merged.sqlite"); + let bytes = fs::read(&merged).map_err(|error| io_error(&merged, error))?; + atomic_write_bytes(&target, &bytes)?; + finalize_sqlite_file(&target)?; + let current = read_memory_snapshot(&target, MemoryDatabaseRole::CurrentTarget)?; + if memory_snapshot_digest(¤t)? != expected { + return Err(LegacyMigrationError::InvalidRequest( + "committed structured Memory database differs from the staged merge".to_string(), + )); + } + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_structured_manifest(context)?; + if !manifest.source_present { + return Ok(()); + } + let current = read_memory_snapshot( + &target_structured_memory_path(context.roots), + MemoryDatabaseRole::CurrentTarget, + )?; + if Some(memory_snapshot_digest(¤t)?) != manifest.merged_digest { + return Err(LegacyMigrationError::InvalidRequest( + "current Memory owner did not read the committed structured data".to_string(), + )); + } + Ok(()) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let Some(manifest) = read_optional_bounded_json::( + &context.layout.stage_root(), + &structured_manifest_path(context), + )? + else { + return Ok(()); + }; + if !manifest.source_present { + return Ok(()); + } + let Some(receipt) = read_structured_commit_receipt(context)? else { + return Ok(()); + }; + if receipt.target_existed != manifest.target_existed + || Some(receipt.merged_digest.as_str()) != manifest.merged_digest.as_deref() + { + return Err(LegacyMigrationError::InvalidRequest( + "structured Memory commit receipt differs from its manifest".to_string(), + )); + } + let target = target_structured_memory_path(context.roots); + let backup = backup_domain_dir(context, "structured-memory").join("memories.sqlite"); + if manifest.target_existed { + let original_digest = manifest.target_digest.as_deref().ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "structured Memory manifest is missing its original target digest".to_string(), + ) + })?; + let merged_digest = manifest.merged_digest.as_deref().ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "structured Memory manifest is missing its merged target digest".to_string(), + ) + })?; + if path_entry_exists(&target)? { + validate_sqlite_family(&context.roots.target_user_root, &target)?; + if let Ok(current) = + read_memory_snapshot(&target, MemoryDatabaseRole::CurrentTarget) + { + let current_digest = memory_snapshot_digest(¤t)?; + if current_digest == original_digest { + return Ok(()); + } + if current_digest != merged_digest { + return Err(LegacyMigrationError::InvalidRequest( + "structured Memory target changed after migration commit; refusing to overwrite it during rollback" + .to_string(), + )); + } + } + } + if path_entry_exists(&backup)? { + validate_sqlite_family(&context.layout.backup_root(), &backup)?; + let original = read_memory_snapshot(&backup, MemoryDatabaseRole::CurrentTarget)?; + if memory_snapshot_digest(&original)? != original_digest { + return Err(LegacyMigrationError::InvalidRequest( + "structured Memory rollback backup differs from the staged original target" + .to_string(), + )); + } + remove_sqlite_sidecars(&target)?; + let bytes = fs::read(&backup).map_err(|error| io_error(&backup, error))?; + atomic_write_bytes(&target, &bytes)?; + remove_sqlite_sidecars(&target)?; + } + } else if path_entry_exists(&target)? { + let should_remove = manifest.merged_digest.as_deref().is_some_and(|expected| { + read_memory_snapshot(&target, MemoryDatabaseRole::CurrentTarget) + .and_then(|snapshot| memory_snapshot_digest(&snapshot)) + .is_ok_and(|actual| actual == expected) + }); + if should_remove { + remove_sqlite_sidecars(&target)?; + remove_file_if_present(&target)?; + } + } + Ok(()) + } +} + +impl LegacyDomainAdapter for FileMemoryAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::FileMemory + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let plan = plan_file_memory(roots)?; + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: if plan.files.is_empty() { + "legacy_file_memory_absent" + } else { + "legacy_file_memory_supported" + } + .to_string(), + severity: if plan.conflicts.is_empty() { + FindingSeverity::Info + } else { + FindingSeverity::Warning + }, + entity_count: plan.files.len() as u64, + logical_bytes: plan.logical_bytes, + source_schema: Some("bitfun.memory-files.v1".to_string()), + migratable: true, + detail: format!( + "{} owner-declared file Memory inputs are eligible for migration", + plan.files.len() + ), + }, + conflicts: plan.conflicts, + target_schema: Some(FILE_MEMORY_SCHEMA.to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let plan = plan_file_memory(context.roots)?; + let domain_root = stage_domain_dir(context, "file-memory"); + reset_directory(&domain_root)?; + let files_root = domain_root.join("files"); + let mut entries = Vec::with_capacity(plan.files.len()); + let mut imported = 0u64; + let mut skipped = 0u64; + for file in plan.files { + if file.action == FileMemoryAction::Import { + let bytes = fs::read(&file.source_path) + .map_err(|error| io_error(&file.source_path, error))?; + atomic_write_bytes(&files_root.join(&file.target_relative), &bytes)?; + imported = imported.saturating_add(1); + } else { + skipped = skipped.saturating_add(1); + } + entries.push(FileMemoryManifestEntry { + source_relative: relative_string(&file.source_relative), + target_relative: relative_string(&file.target_relative), + action: file.action, + expected_hash: file.expected_hash, + }); + } + let manifest = FileMemoryManifest { + entries, + imported, + skipped, + conflicts: plan.conflicts.len() as u64, + }; + atomic_write_json(&file_manifest_path(context), &manifest)?; + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported, + skipped, + conflicts: manifest.conflicts, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_file_manifest(context)?; + let files_root = stage_domain_dir(context, "file-memory").join("files"); + for (_, entry) in imported_file_entries(&manifest) { + let relative = validated_memory_relative_path(&entry.target_relative)?; + let path = files_root.join(relative); + validate_regular_file(&files_root, &path)?; + if normalized_file_hash(&path)? != entry.expected_hash { + return Err(LegacyMigrationError::InvalidRequest(format!( + "staged file Memory item {} differs from its manifest", + entry.target_relative + ))); + } + } + Ok(()) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_file_manifest(context)?; + let source_root = stage_domain_dir(context, "file-memory").join("files"); + let target_root = target_file_memory_root(context.roots); + if path_entry_exists(&target_root)? { + validate_directory(&context.roots.target_home_root, &target_root)?; + } + for (entry_index, entry) in imported_file_entries(&manifest) { + let relative = validated_memory_relative_path(&entry.target_relative)?; + let source = source_root.join(&relative); + let target = target_root.join(&relative); + if path_entry_exists(&target)? { + validate_regular_file(&target_root, &target)?; + let receipt = read_file_commit_receipt(context, entry_index)?; + if normalized_file_hash(&target)? == entry.expected_hash + && receipt.as_ref().is_some_and(|receipt| { + receipt.target_relative == entry.target_relative + && receipt.expected_hash == entry.expected_hash + }) + { + continue; + } + return Err(LegacyMigrationError::InvalidRequest(format!( + "file Memory target changed after staging: {}", + entry.target_relative + ))); + } + atomic_write_json( + &file_commit_receipt_path(context, entry_index), + &FileMemoryCommitReceipt { + target_relative: entry.target_relative.clone(), + expected_hash: entry.expected_hash.clone(), + }, + )?; + ensure_safe_target_parent(context.roots, &target_root, &target)?; + let bytes = fs::read(&source).map_err(|error| io_error(&source, error))?; + atomic_write_bytes(&target, &bytes)?; + } + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_file_manifest(context)?; + let target_root = target_file_memory_root(context.roots); + if path_entry_exists(&target_root)? { + validate_directory(&context.roots.target_home_root, &target_root)?; + } + for (_, entry) in imported_file_entries(&manifest) { + let relative = validated_memory_relative_path(&entry.target_relative)?; + let target = target_root.join(relative); + validate_regular_file(&target_root, &target)?; + if normalized_file_hash(&target)? != entry.expected_hash { + return Err(LegacyMigrationError::InvalidRequest(format!( + "current Memory owner could not validate file {}", + entry.target_relative + ))); + } + } + Ok(()) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let Some(manifest) = read_optional_bounded_json::( + &context.layout.stage_root(), + &file_manifest_path(context), + )? + else { + return Ok(()); + }; + let target_root = target_file_memory_root(context.roots); + if path_entry_exists(&target_root)? { + validate_directory(&context.roots.target_home_root, &target_root)?; + } + for (entry_index, entry) in imported_file_entries(&manifest).rev() { + let Some(receipt) = read_file_commit_receipt(context, entry_index)? else { + continue; + }; + if receipt.target_relative != entry.target_relative + || receipt.expected_hash != entry.expected_hash + { + return Err(LegacyMigrationError::InvalidRequest(format!( + "file Memory commit receipt differs for {}", + entry.target_relative + ))); + } + let relative = validated_memory_relative_path(&entry.target_relative)?; + let target = target_root.join(&relative); + if path_entry_exists(&target)? + && validate_regular_file(&target_root, &target).is_ok() + && normalized_file_hash(&target)? == entry.expected_hash + { + fs::remove_file(&target).map_err(|error| io_error(&target, error))?; + prune_empty_memory_parents(&target_root, target.parent())?; + } + } + if path_entry_exists(&target_root)? { + validate_directory(&context.roots.target_home_root, &target_root)?; + } + if path_entry_exists(&target_root)? && is_directory_empty(&target_root)? { + fs::remove_dir(&target_root).map_err(|error| io_error(&target_root, error))?; + } + Ok(()) + } +} + +fn preview_structured_merge( + source: &[MemoryRecord], + target: &[MemoryRecord], +) -> StructuredMergeOutcome { + let target_by_id = target + .iter() + .map(|record| { + ( + record.session_id.as_str(), + normalized_memory_content_hash(record), + ) + }) + .collect::>(); + let mut occupied_hashes = target_by_id.values().cloned().collect::>(); + let mut outcome = StructuredMergeOutcome::default(); + for record in source { + let content_hash = normalized_memory_content_hash(record); + if let Some(target_hash) = target_by_id.get(record.session_id.as_str()) { + if target_hash == &content_hash { + outcome.duplicate = outcome.duplicate.saturating_add(1); + outcome.conflicts.push(structured_conflict( + record, + "structured_memory_duplicate", + "The target contains the same stable id and normalized content.", + ConflictResolution::DuplicateSkipped, + )); + } else { + outcome.target_wins = outcome.target_wins.saturating_add(1); + outcome.conflicts.push(structured_conflict( + record, + "structured_memory_id_conflict", + "The target keeps its record because the stable id is also a Session reference.", + ConflictResolution::TargetWins, + )); + } + continue; + } + if !occupied_hashes.insert(content_hash) { + outcome.duplicate = outcome.duplicate.saturating_add(1); + outcome.conflicts.push(structured_conflict( + record, + "structured_memory_content_duplicate", + "The normalized content already exists under another stable id.", + ConflictResolution::DuplicateSkipped, + )); + continue; + } + outcome.imports.push(record.clone()); + } + outcome +} + +fn structured_conflict( + record: &MemoryRecord, + code: &str, + target_summary: &str, + resolution: ConflictResolution, +) -> MigrationConflict { + MigrationConflict { + domain: MigrationDomainId::StructuredMemory, + code: code.to_string(), + source_summary: format!("Legacy structured Memory record {}", record.session_id), + target_summary: target_summary.to_string(), + resolution, + } +} + +fn plan_file_memory(roots: &MigrationRoots) -> LegacyMigrationResult { + let source_root = source_file_memory_root(roots); + let source = collect_memory_files(&roots.legacy_home_root, &source_root)?; + let target_root = target_file_memory_root(roots); + let target = collect_memory_files(&roots.target_home_root, &target_root)?; + let target_by_path = target + .iter() + .map(|file| (file.relative.clone(), file.hash.clone())) + .collect::>(); + let mut occupied_paths = target_by_path.clone(); + let mut occupied_hashes = target + .iter() + .map(|file| file.hash.clone()) + .collect::>(); + let mut files = Vec::with_capacity(source.len()); + let mut conflicts = Vec::new(); + let logical_bytes = source.iter().map(|file| file.bytes).sum(); + + for file in source { + let mut target_relative = file.relative.clone(); + let mut action = FileMemoryAction::Import; + if occupied_hashes.contains(&file.hash) { + action = FileMemoryAction::Duplicate; + conflicts.push(file_conflict( + &file.relative, + &target_relative, + "file_memory_content_duplicate", + ConflictResolution::DuplicateSkipped, + )); + } else if occupied_paths.contains_key(&target_relative) { + if file.kind == MemoryWorkspaceFileKind::AdHocNote { + target_relative = remapped_note_path(&file.relative, &file.hash, &occupied_paths); + conflicts.push(file_conflict( + &file.relative, + &target_relative, + "file_memory_path_remapped", + ConflictResolution::SourceRemapped, + )); + } else { + action = FileMemoryAction::TargetWins; + conflicts.push(file_conflict( + &file.relative, + &target_relative, + "file_memory_path_conflict", + ConflictResolution::TargetWins, + )); + } + } + if action == FileMemoryAction::Import { + occupied_paths.insert(target_relative.clone(), file.hash.clone()); + occupied_hashes.insert(file.hash.clone()); + } + files.push(PlannedMemoryFile { + source_relative: file.relative, + target_relative, + source_path: file.path, + action, + expected_hash: file.hash, + }); + } + Ok(FileMemoryPlan { + files, + conflicts, + logical_bytes, + }) +} + +fn collect_memory_files( + boundary_root: &Path, + memory_root: &Path, +) -> LegacyMigrationResult> { + if !path_entry_exists(memory_root)? { + return Ok(Vec::new()); + } + validate_directory(boundary_root, memory_root)?; + let mut files = Vec::new(); + for name in ["MEMORY.md", "memory_summary.md"] { + let path = memory_root.join(name); + if path_entry_exists(&path)? { + push_memory_file(memory_root, &path, &mut files)?; + } + } + let extensions = memory_root.join("extensions"); + let ad_hoc = extensions.join("ad_hoc"); + let notes = ad_hoc.join("notes"); + for directory in [&extensions, &ad_hoc, ¬es] { + if path_entry_exists(directory)? { + validate_directory(memory_root, directory)?; + } else { + return finish_memory_files(files); + } + } + for entry in fs::read_dir(¬es).map_err(|error| io_error(¬es, error))? { + let entry = entry.map_err(|error| io_error(¬es, error))?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(&path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path)); + } + if metadata.is_dir() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "nested file Memory directories are unsupported: {}", + path.display() + ))); + } + if classify_memory_workspace_file( + path.strip_prefix(memory_root) + .map_err(|_| LegacyMigrationError::PathEscape(path.clone()))?, + ) + .is_some() + { + push_memory_file(memory_root, &path, &mut files)?; + } + } + finish_memory_files(files) +} + +fn finish_memory_files( + mut files: Vec, +) -> LegacyMigrationResult> { + files.sort_by(|left, right| left.relative.cmp(&right.relative)); + if files.len() > MAX_MEMORY_FILE_COUNT { + return Err(LegacyMigrationError::ResourceLimit(format!( + "file Memory contains more than {MAX_MEMORY_FILE_COUNT} owner-declared files" + ))); + } + let total = files.iter().map(|file| file.bytes).sum::(); + if total > MAX_MEMORY_TOTAL_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "file Memory exceeds {MAX_MEMORY_TOTAL_BYTES} bytes" + ))); + } + Ok(files) +} + +fn push_memory_file( + memory_root: &Path, + path: &Path, + files: &mut Vec, +) -> LegacyMigrationResult<()> { + validate_regular_file(memory_root, path)?; + let relative = path + .strip_prefix(memory_root) + .map_err(|_| LegacyMigrationError::PathEscape(path.to_path_buf()))? + .to_path_buf(); + let kind = classify_memory_workspace_file(&relative).ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!( + "file is outside the current Memory owner contract: {}", + relative.display() + )) + })?; + let bytes = fs::metadata(path) + .map_err(|error| io_error(path, error))? + .len(); + if bytes > MAX_MEMORY_FILE_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "file Memory item exceeds {MAX_MEMORY_FILE_BYTES} bytes: {}", + relative.display() + ))); + } + files.push(MemoryFileFact { + relative, + path: path.to_path_buf(), + hash: normalized_file_hash(path)?, + bytes, + kind, + }); + Ok(()) +} + +fn file_conflict( + source_relative: &Path, + target_relative: &Path, + code: &str, + resolution: ConflictResolution, +) -> MigrationConflict { + MigrationConflict { + domain: MigrationDomainId::FileMemory, + code: code.to_string(), + source_summary: format!( + "Legacy file Memory item {}", + relative_string(source_relative) + ), + target_summary: format!( + "Current file Memory path {}", + relative_string(target_relative) + ), + resolution, + } +} + +fn remapped_note_path(source: &Path, hash: &str, occupied: &BTreeMap) -> PathBuf { + let parent = source.parent().unwrap_or_else(|| Path::new("")); + let stem = source + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("legacy-note"); + let short_hash = hash.get(..8).unwrap_or(hash); + for suffix in 0u32.. { + let name = if suffix == 0 { + format!("{stem}-from-bitfun-{short_hash}.md") + } else { + format!("{stem}-from-bitfun-{short_hash}-{suffix}.md") + }; + let candidate = parent.join(name); + if !occupied.contains_key(&candidate) { + return candidate; + } + } + unreachable!("u32 note remap namespace should not be exhausted") +} + +fn normalized_memory_content_hash(record: &MemoryRecord) -> String { + let mut hasher = Sha256::new(); + hasher.update(normalize_text(&record.raw_memory).as_bytes()); + hasher.update([0]); + hasher.update(normalize_text(&record.rollout_summary).as_bytes()); + hex::encode(hasher.finalize()) +} + +fn normalized_file_hash(path: &Path) -> LegacyMigrationResult { + let bytes = fs::read(path).map_err(|error| io_error(path, error))?; + let text = std::str::from_utf8(&bytes).map_err(|_| { + LegacyMigrationError::UnsupportedSource(format!( + "file Memory item is not UTF-8: {}", + path.display() + )) + })?; + let mut hasher = Sha256::new(); + hasher.update(normalize_text(text).as_bytes()); + Ok(hex::encode(hasher.finalize())) +} + +fn normalize_text(text: &str) -> String { + let normalized = text + .replace("\r\n", "\n") + .replace('\r', "\n") + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + normalized.trim_end_matches('\n').to_string() +} + +fn memory_snapshot_digest(snapshot: &MemoryStoreSnapshot) -> LegacyMigrationResult { + let bytes = serde_json::to_vec(snapshot) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + Ok(hex::encode(Sha256::digest(bytes))) +} + +fn read_consistent_scan_snapshot( + roots: &MigrationRoots, + boundary: &Path, + source: &Path, + label: &str, + role: MemoryDatabaseRole, +) -> LegacyMigrationResult { + validate_sqlite_family(boundary, source)?; + let scan_root = roots.migration_root().join("scan-snapshots"); + fs::create_dir_all(&scan_root).map_err(|error| io_error(&scan_root, error))?; + let snapshot = scan_root.join(format!("{label}-{}.sqlite", Uuid::new_v4())); + let result = snapshot_sqlite_read_only(source, &snapshot) + .and_then(|()| read_memory_snapshot(&snapshot, role)); + let cleanup = remove_sqlite_family(&snapshot); + if scan_root.exists() && is_directory_empty(&scan_root).unwrap_or(false) { + let _ = fs::remove_dir(&scan_root); + } + match (result, cleanup) { + (Ok(snapshot), Ok(())) => Ok(snapshot), + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + } +} + +#[derive(Debug, Clone, Copy)] +enum MemoryDatabaseRole { + LegacySource, + CurrentTarget, + StagedCurrent, +} + +fn read_memory_snapshot( + path: &Path, + role: MemoryDatabaseRole, +) -> LegacyMigrationResult { + validate_sqlite(path)?; + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| db_error(path, error))?; + read_memory_store_snapshot(&connection).map_err(|error| match role { + MemoryDatabaseRole::LegacySource => LegacyMigrationError::UnsupportedSource(format!( + "legacy structured Memory schema is not supported: {error}" + )), + MemoryDatabaseRole::CurrentTarget | MemoryDatabaseRole::StagedCurrent => { + LegacyMigrationError::InvalidRequest(format!( + "current structured Memory schema is invalid: {error}" + )) + } + }) +} + +fn verify_structured_target_state( + target: &Path, + manifest: &StructuredMemoryManifest, +) -> LegacyMigrationResult<()> { + if !manifest.target_existed { + if path_entry_exists(target)? { + return Err(LegacyMigrationError::InvalidRequest( + "structured Memory target appeared after staging".to_string(), + )); + } + return Ok(()); + } + if !path_entry_exists(target)? { + return Err(LegacyMigrationError::InvalidRequest( + "structured Memory target disappeared after staging".to_string(), + )); + } + let current = read_memory_snapshot(target, MemoryDatabaseRole::CurrentTarget)?; + if Some(memory_snapshot_digest(¤t)?) != manifest.target_digest { + return Err(LegacyMigrationError::InvalidRequest( + "structured Memory target changed after staging".to_string(), + )); + } + Ok(()) +} + +fn validate_sqlite_family(boundary: &Path, database: &Path) -> LegacyMigrationResult<()> { + validate_regular_file(boundary, database)?; + for sidecar in sqlite_sidecars(database) { + if path_entry_exists(&sidecar)? { + validate_regular_file(boundary, &sidecar)?; + } + } + Ok(()) +} + +fn sqlite_family_size(database: &Path) -> LegacyMigrationResult { + let mut total = fs::metadata(database) + .map_err(|error| io_error(database, error))? + .len(); + for sidecar in sqlite_sidecars(database) { + if path_entry_exists(&sidecar)? { + total = total.saturating_add( + fs::metadata(&sidecar) + .map_err(|error| io_error(&sidecar, error))? + .len(), + ); + } + } + Ok(total) +} + +fn finalize_sqlite_file(path: &Path) -> LegacyMigrationResult<()> { + let connection = Connection::open(path).map_err(|error| db_error(path, error))?; + connection + .execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode = DELETE;") + .map_err(|error| db_error(path, error))?; + drop(connection); + remove_sqlite_sidecars(path) +} + +fn remove_sqlite_family(path: &Path) -> LegacyMigrationResult<()> { + remove_file_if_present(path)?; + remove_sqlite_sidecars(path) +} + +fn remove_sqlite_sidecars(path: &Path) -> LegacyMigrationResult<()> { + for sidecar in sqlite_sidecars(path) { + remove_file_if_present(&sidecar)?; + } + Ok(()) +} + +fn sqlite_sidecars(path: &Path) -> [PathBuf; 2] { + [ + PathBuf::from(format!("{}-wal", path.display())), + PathBuf::from(format!("{}-shm", path.display())), + ] +} + +fn remove_file_if_present(path: &Path) -> LegacyMigrationResult<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(io_error(path, error)), + } +} + +fn path_entry_exists(path: &Path) -> LegacyMigrationResult { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(io_error(path, error)), + } +} + +fn reset_directory(path: &Path) -> LegacyMigrationResult<()> { + if path_entry_exists(path)? { + let metadata = fs::symlink_metadata(path).map_err(|error| io_error(path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path.to_path_buf())); + } + fs::remove_dir_all(path).map_err(|error| io_error(path, error))?; + } + fs::create_dir_all(path).map_err(|error| io_error(path, error)) +} + +fn validate_directory(boundary: &Path, path: &Path) -> LegacyMigrationResult<()> { + let metadata = fs::symlink_metadata(path).map_err(|error| io_error(path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path.to_path_buf())); + } + if !metadata.is_dir() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "expected a directory at {}", + path.display() + ))); + } + let canonical_boundary = + fs::canonicalize(boundary).map_err(|error| io_error(boundary, error))?; + let canonical_path = fs::canonicalize(path).map_err(|error| io_error(path, error))?; + if !canonical_path.starts_with(canonical_boundary) { + return Err(LegacyMigrationError::PathEscape(path.to_path_buf())); + } + Ok(()) +} + +fn ensure_safe_target_parent( + roots: &MigrationRoots, + target_root: &Path, + target: &Path, +) -> LegacyMigrationResult<()> { + if !path_entry_exists(&roots.target_home_root)? { + fs::create_dir_all(&roots.target_home_root) + .map_err(|error| io_error(&roots.target_home_root, error))?; + } + validate_directory(&roots.target_home_root, &roots.target_home_root)?; + if !path_entry_exists(target_root)? { + fs::create_dir(target_root).map_err(|error| io_error(target_root, error))?; + } + validate_directory(&roots.target_home_root, target_root)?; + let parent = target.parent().ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!( + "file Memory target has no parent: {}", + target.display() + )) + })?; + let relative_parent = parent + .strip_prefix(target_root) + .map_err(|_| LegacyMigrationError::PathEscape(parent.to_path_buf()))?; + let mut current = target_root.to_path_buf(); + for component in relative_parent.components() { + let Component::Normal(component) = component else { + return Err(LegacyMigrationError::PathEscape(parent.to_path_buf())); + }; + current.push(component); + if path_entry_exists(¤t)? { + validate_directory(target_root, ¤t)?; + } else { + fs::create_dir(¤t).map_err(|error| io_error(¤t, error))?; + } + } + Ok(()) +} + +fn validated_memory_relative_path(raw: &str) -> LegacyMigrationResult { + let relative = PathBuf::from(raw); + if classify_memory_workspace_file(&relative).is_none() + || relative + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(LegacyMigrationError::PathEscape(relative)); + } + Ok(relative) +} + +fn imported_file_entries( + manifest: &FileMemoryManifest, +) -> impl DoubleEndedIterator { + manifest + .entries + .iter() + .enumerate() + .filter(|(_, entry)| entry.action == FileMemoryAction::Import) +} + +fn prune_empty_memory_parents( + target_root: &Path, + mut parent: Option<&Path>, +) -> LegacyMigrationResult<()> { + while let Some(path) = parent { + if path == target_root || !path.starts_with(target_root) || !is_directory_empty(path)? { + break; + } + fs::remove_dir(path).map_err(|error| io_error(path, error))?; + parent = path.parent(); + } + Ok(()) +} + +fn is_directory_empty(path: &Path) -> LegacyMigrationResult { + Ok(fs::read_dir(path) + .map_err(|error| io_error(path, error))? + .next() + .is_none()) +} + +#[cfg(windows)] +fn is_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +fn empty_domain_scan( + domain: MigrationDomainId, + code: &str, + source_schema: &str, + target_schema: &str, + detail: &str, +) -> DomainScan { + DomainScan { + finding: ScanFinding { + domain, + code: code.to_string(), + source_schema: Some(source_schema.to_string()), + migratable: true, + detail: detail.to_string(), + ..ScanFinding::default() + }, + conflicts: Vec::new(), + target_schema: Some(target_schema.to_string()), + dependencies: Vec::new(), + } +} + +fn source_structured_memory_path(roots: &MigrationRoots) -> PathBuf { + roots.legacy_user_root.join(STRUCTURED_MEMORY_RELATIVE_PATH) +} + +fn target_structured_memory_path(roots: &MigrationRoots) -> PathBuf { + roots.target_user_root.join(STRUCTURED_MEMORY_RELATIVE_PATH) +} + +fn source_file_memory_root(roots: &MigrationRoots) -> PathBuf { + roots.legacy_home_root.join("memories") +} + +fn target_file_memory_root(roots: &MigrationRoots) -> PathBuf { + roots.target_home_root.join("memories") +} + +fn structured_manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "structured-memory").join("manifest.json") +} + +fn file_manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "file-memory").join("manifest.json") +} + +fn structured_commit_receipt_path(context: &DomainContext<'_>) -> PathBuf { + backup_domain_dir(context, "structured-memory").join("commit-receipt.json") +} + +fn file_commit_receipt_path(context: &DomainContext<'_>, entry_index: usize) -> PathBuf { + backup_domain_dir(context, "file-memory") + .join("commit-receipts") + .join(format!("{entry_index:04}.json")) +} + +fn read_structured_manifest( + context: &DomainContext<'_>, +) -> LegacyMigrationResult { + read_bounded_json( + &context.layout.stage_root(), + &structured_manifest_path(context), + ) +} + +fn read_file_manifest(context: &DomainContext<'_>) -> LegacyMigrationResult { + read_bounded_json(&context.layout.stage_root(), &file_manifest_path(context)) +} + +fn read_structured_commit_receipt( + context: &DomainContext<'_>, +) -> LegacyMigrationResult> { + read_optional_bounded_json( + &context.layout.backup_root(), + &structured_commit_receipt_path(context), + ) +} + +fn read_file_commit_receipt( + context: &DomainContext<'_>, + entry_index: usize, +) -> LegacyMigrationResult> { + read_optional_bounded_json( + &context.layout.backup_root(), + &file_commit_receipt_path(context, entry_index), + ) +} + +fn relative_string(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn db_error(path: &Path, error: rusqlite::Error) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!( + "SQLite operation failed at {}: {error}", + path.display() + )) +} + +fn owner_error(error: impl std::fmt::Display) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!("Memory persistence owner rejected data: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::legacy_migration::adapters_for_groups; + use openbitfun_legacy_migration::{ + probe_legacy_source, CancellationToken, CrashInjector, CrashPoint, MigrationEngine, + NoCrashInjection, ProbeLimits, + }; + use openbitfun_product_domains::legacy_migration::{ + MigrationGroupId, MigrationRunStatus, MigrationSelection, + }; + use std::io::Read; + use std::sync::atomic::{AtomicBool, Ordering}; + + struct CrashOnce { + point: CrashPoint, + fired: AtomicBool, + } + + impl CrashInjector for CrashOnce { + fn should_crash(&self, point: CrashPoint) -> bool { + point == self.point && !self.fired.swap(true, Ordering::AcqRel) + } + } + + #[test] + fn structured_memory_uses_wal_target_priority_hash_dedup_and_owner_validation() { + let temp = test_tempdir("structured-memory"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_source_memory(&roots, true); + let target_path = target_structured_memory_path(&roots); + fs::create_dir_all(target_path.parent().unwrap()).unwrap(); + let target_connection = Connection::open(&target_path).unwrap(); + initialize_memory_schema(&target_connection).unwrap(); + upsert_memory_record( + &target_connection, + &memory_record( + "session-1", + "Current target fact.", + "Current target summary.", + ), + true, + ) + .unwrap(); + upsert_memory_record( + &target_connection, + &memory_record( + "target-duplicate", + "Repeated durable fact.\n", + "Repeated summary.", + ), + true, + ) + .unwrap(); + target_connection + .execute( + "INSERT INTO jobs (kind, job_key, status, retry_remaining) VALUES ('memory_stage1', 'target-job', 'done', 3)", + [], + ) + .unwrap(); + drop(target_connection); + + let source_hash = hash_source_roots(&roots); + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection.clone(), &CancellationToken::default()) + .unwrap(); + let structured_conflicts = plan + .conflicts + .iter() + .filter(|conflict| conflict.domain == MigrationDomainId::StructuredMemory) + .collect::>(); + assert!(structured_conflicts + .iter() + .any(|conflict| conflict.code == "structured_memory_id_conflict")); + assert!(structured_conflicts + .iter() + .any(|conflict| conflict.code == "structured_memory_content_duplicate")); + assert!(structured_conflicts.iter().all(|conflict| { + !conflict + .source_summary + .contains("Synthetic migration fixture memory") + && !conflict.target_summary.contains("Current target fact") + })); + + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert!(matches!( + report.status, + MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings + )); + let target_connection = Connection::open(&target_path).unwrap(); + let snapshot = read_memory_store_snapshot(&target_connection).unwrap(); + assert_eq!(snapshot.records.len(), 4); + assert_eq!(snapshot.jobs.len(), 1); + assert_eq!(snapshot.jobs[0].job_key, "target-job"); + assert_eq!( + snapshot + .records + .iter() + .find(|record| record.session_id == "session-1") + .unwrap() + .raw_memory, + "Current target fact." + ); + assert!(snapshot + .records + .iter() + .any(|record| record.session_id == "session-memory-import")); + assert!(snapshot + .records + .iter() + .any(|record| record.session_id == "session-memory-wal")); + assert!(!snapshot + .records + .iter() + .any(|record| record.session_id == "session-memory-duplicate")); + drop(target_connection); + assert!(!PathBuf::from(format!("{}-wal", target_path.display())).exists()); + assert!(!PathBuf::from(format!("{}-shm", target_path.display())).exists()); + let report_json = serde_json::to_string(&report).unwrap(); + for private_text in [ + "Synthetic migration fixture memory", + "Current target fact", + "Repeated durable fact", + "WAL-only durable fact", + ] { + assert!(!report_json.contains(private_text)); + } + assert_eq!(hash_source_roots(&roots), source_hash); + + let repeated_source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let repeated_plan = engine + .plan(&repeated_source, selection, &CancellationToken::default()) + .unwrap(); + engine + .execute( + &repeated_plan, + &CancellationToken::default(), + &NoCrashInjection, + ) + .unwrap(); + let target_connection = Connection::open(&target_path).unwrap(); + assert_eq!( + read_memory_store_snapshot(&target_connection) + .unwrap() + .records + .len(), + 4 + ); + assert_eq!(hash_source_roots(&roots), source_hash); + drop(source_connection); + } + + #[test] + fn structured_memory_recovers_after_commit_before_journal() { + let temp = test_tempdir("structured-memory-crash"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_source_memory(&roots, false); + let source_hash = hash_source_roots(&roots); + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let crash = CrashOnce { + point: CrashPoint::AfterCommit(MigrationDomainId::StructuredMemory), + fired: AtomicBool::new(false), + }; + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &crash), + Err(LegacyMigrationError::InjectedCrash( + CrashPoint::AfterCommit(MigrationDomainId::StructuredMemory) + )) + )); + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert!(matches!( + report.status, + MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings + )); + let target = Connection::open(target_structured_memory_path(&roots)).unwrap(); + let snapshot = read_memory_store_snapshot(&target).unwrap(); + assert_eq!(snapshot.records.len(), 1); + assert!(snapshot.jobs.is_empty()); + assert_eq!(hash_source_roots(&roots), source_hash); + drop(source_connection); + } + + #[test] + fn structured_memory_rollback_preserves_post_commit_target_changes() { + let temp = test_tempdir("structured-memory-rollback-race"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_source_memory(&roots, false); + let target_path = target_structured_memory_path(&roots); + fs::create_dir_all(target_path.parent().unwrap()).unwrap(); + let target = Connection::open(&target_path).unwrap(); + initialize_memory_schema(&target).unwrap(); + upsert_memory_record( + &target, + &memory_record( + "original-target", + "Original target fact.", + "Original target summary.", + ), + true, + ) + .unwrap(); + drop(target); + + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let crash = CrashOnce { + point: CrashPoint::AfterCommit(MigrationDomainId::StructuredMemory), + fired: AtomicBool::new(false), + }; + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &crash), + Err(LegacyMigrationError::InjectedCrash( + CrashPoint::AfterCommit(MigrationDomainId::StructuredMemory) + )) + )); + + let target = Connection::open(&target_path).unwrap(); + upsert_memory_record( + &target, + &memory_record( + "post-commit-target", + "Post-commit target fact.", + "Post-commit target summary.", + ), + true, + ) + .unwrap(); + drop(target); + + let error = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap_err(); + assert!(error + .to_string() + .contains("structured Memory target changed after staging")); + let target = Connection::open(&target_path).unwrap(); + let snapshot = read_memory_store_snapshot(&target).unwrap(); + assert!(snapshot + .records + .iter() + .any(|record| record.session_id == "original-target")); + assert!(snapshot + .records + .iter() + .any(|record| record.session_id == "post-commit-target")); + assert!(snapshot + .records + .iter() + .any(|record| record.session_id == "session-1")); + drop(source_connection); + } + + #[test] + fn structured_memory_does_not_remove_a_target_that_appears_after_staging() { + let temp = test_tempdir("structured-target-race"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_source_memory(&roots, false); + let source_hash = hash_source_roots(&roots); + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let crash = CrashOnce { + point: CrashPoint::AfterStageValidated(MigrationDomainId::StructuredMemory), + fired: AtomicBool::new(false), + }; + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &crash), + Err(LegacyMigrationError::InjectedCrash( + CrashPoint::AfterStageValidated(MigrationDomainId::StructuredMemory) + )) + )); + + let target_path = target_structured_memory_path(&roots); + fs::create_dir_all(target_path.parent().unwrap()).unwrap(); + let target = Connection::open(&target_path).unwrap(); + initialize_memory_schema(&target).unwrap(); + upsert_memory_record( + &target, + &memory_record("new-target", "New target fact.", "New target summary."), + true, + ) + .unwrap(); + drop(target); + + let error = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap_err(); + assert!(error + .to_string() + .contains("structured Memory target appeared after staging")); + let target = Connection::open(&target_path).unwrap(); + let snapshot = read_memory_store_snapshot(&target).unwrap(); + assert_eq!(snapshot.records.len(), 1); + assert_eq!(snapshot.records[0].session_id, "new-target"); + assert_eq!(hash_source_roots(&roots), source_hash); + drop(source_connection); + } + + #[test] + fn file_memory_preserves_target_remaps_notes_and_excludes_generated_files() { + let temp = test_tempdir("file-memory"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_source_memory(&roots, false); + let source_memory = source_file_memory_root(&roots); + let source_notes = source_memory.join("extensions/ad_hoc/notes"); + fs::create_dir_all(&source_notes).unwrap(); + fs::write( + source_notes.join("collision.md"), + "Legacy collision note.\r\n", + ) + .unwrap(); + fs::write(source_notes.join("duplicate.md"), "Duplicate note.\n").unwrap(); + fs::write( + source_memory.join("raw_memories.md"), + "Generated raw memory.", + ) + .unwrap(); + fs::write( + source_memory.join("phase2_workspace_diff.md"), + "Temporary diff.", + ) + .unwrap(); + fs::create_dir_all(source_memory.join("rollout_summaries")).unwrap(); + fs::write( + source_memory.join("rollout_summaries/generated.md"), + "Generated rollout summary.", + ) + .unwrap(); + fs::write( + source_memory.join("extensions/ad_hoc/instructions.md"), + "Generated owner instructions.", + ) + .unwrap(); + + let target_memory = target_file_memory_root(&roots); + let target_notes = target_memory.join("extensions/ad_hoc/notes"); + fs::create_dir_all(&target_notes).unwrap(); + fs::write(target_memory.join("MEMORY.md"), "Current target Memory.\n").unwrap(); + fs::write( + target_notes.join("collision.md"), + "Current collision note.\n", + ) + .unwrap(); + fs::write(target_notes.join("existing.md"), "Duplicate note.\r\n").unwrap(); + + let source_hash = hash_source_roots(&roots); + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let file_conflicts = plan + .conflicts + .iter() + .filter(|conflict| conflict.domain == MigrationDomainId::FileMemory) + .collect::>(); + assert!(file_conflicts + .iter() + .any(|conflict| conflict.code == "file_memory_path_conflict")); + assert!(file_conflicts + .iter() + .any(|conflict| conflict.code == "file_memory_path_remapped")); + assert!(file_conflicts + .iter() + .any(|conflict| conflict.code == "file_memory_content_duplicate")); + + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert_eq!( + fs::read_to_string(target_memory.join("MEMORY.md")).unwrap(), + "Current target Memory.\n" + ); + assert_eq!( + fs::read_to_string(target_notes.join("collision.md")).unwrap(), + "Current collision note.\n" + ); + let remapped = fs::read_dir(&target_notes) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("collision-from-bitfun-")) + }) + .expect("colliding ad-hoc note should be deterministically remapped"); + assert_eq!( + fs::read_to_string(remapped).unwrap(), + "Legacy collision note.\r\n" + ); + assert!(!target_notes.join("duplicate.md").exists()); + assert!(!target_memory.join("raw_memories.md").exists()); + assert!(!target_memory.join("phase2_workspace_diff.md").exists()); + assert!(!target_memory.join("rollout_summaries").exists()); + assert!(!target_memory + .join("extensions/ad_hoc/instructions.md") + .exists()); + let report_json = serde_json::to_string(&report).unwrap(); + for private_text in [ + "Synthetic memory fixture", + "Legacy collision note", + "Current target Memory", + "Duplicate note", + ] { + assert!(!report_json.contains(private_text)); + } + assert_eq!(hash_source_roots(&roots), source_hash); + drop(source_connection); + } + + #[test] + fn file_memory_recovers_after_commit_before_journal_without_duplicates() { + let temp = test_tempdir("file-memory-crash"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_source_memory(&roots, false); + let source_hash = hash_source_roots(&roots); + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let crash = CrashOnce { + point: CrashPoint::AfterCommit(MigrationDomainId::FileMemory), + fired: AtomicBool::new(false), + }; + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &crash), + Err(LegacyMigrationError::InjectedCrash( + CrashPoint::AfterCommit(MigrationDomainId::FileMemory) + )) + )); + let target_index = target_file_memory_root(&roots).join("MEMORY.md"); + let committed = fs::read(&target_index).unwrap(); + + engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert_eq!(fs::read(&target_index).unwrap(), committed); + assert_eq!( + collect_memory_files(&roots.target_home_root, &target_file_memory_root(&roots)) + .unwrap() + .len(), + 1 + ); + assert_eq!(hash_source_roots(&roots), source_hash); + drop(source_connection); + } + + #[test] + fn file_memory_does_not_claim_an_equal_target_that_appears_after_staging() { + let temp = test_tempdir("file-target-race"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let source_connection = materialize_source_memory(&roots, false); + let source_hash = hash_source_roots(&roots); + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let crash = CrashOnce { + point: CrashPoint::AfterStageValidated(MigrationDomainId::FileMemory), + fired: AtomicBool::new(false), + }; + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &crash), + Err(LegacyMigrationError::InjectedCrash( + CrashPoint::AfterStageValidated(MigrationDomainId::FileMemory) + )) + )); + + let target = target_file_memory_root(&roots).join("MEMORY.md"); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + let source_bytes = fs::read(source_file_memory_root(&roots).join("MEMORY.md")).unwrap(); + fs::write(&target, &source_bytes).unwrap(); + let error = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap_err(); + assert!(error + .to_string() + .contains("file Memory target changed after staging")); + assert_eq!(fs::read(&target).unwrap(), source_bytes); + assert_eq!(hash_source_roots(&roots), source_hash); + drop(source_connection); + } + + #[test] + fn invalid_structured_schema_and_cancellation_leave_source_unchanged() { + let temp = test_tempdir("memory-source-failure"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let invalid_path = source_structured_memory_path(&roots); + fs::create_dir_all(invalid_path.parent().unwrap()).unwrap(); + let invalid = Connection::open(&invalid_path).unwrap(); + invalid + .execute_batch( + "CREATE TABLE memories (id TEXT PRIMARY KEY, content TEXT NOT NULL, created_at INTEGER NOT NULL);", + ) + .unwrap(); + drop(invalid); + let source_hash = hash_source_roots(&roots); + let selection = memory_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + assert!(engine + .plan(&source, selection.clone(), &CancellationToken::default()) + .unwrap_err() + .to_string() + .contains("legacy structured Memory schema is not supported")); + assert_eq!(hash_source_roots(&roots), source_hash); + + fs::remove_file(&invalid_path).unwrap(); + let source_connection = materialize_source_memory(&roots, false); + let source_hash = hash_source_roots(&roots); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let cancellation = CancellationToken::default(); + cancellation.cancel(); + assert!(matches!( + engine.plan(&source, selection, &cancellation), + Err(LegacyMigrationError::Cancelled) + )); + assert_eq!(hash_source_roots(&roots), source_hash); + drop(source_connection); + } + + #[test] + fn file_memory_manifest_rejects_path_traversal() { + assert!(matches!( + validated_memory_relative_path("../MEMORY.md"), + Err(LegacyMigrationError::PathEscape(_)) + )); + assert!(matches!( + validated_memory_relative_path("extensions/ad_hoc/notes/nested/note.md"), + Err(LegacyMigrationError::PathEscape(_)) + )); + } + + #[test] + fn content_normalization_preserves_leading_markdown_indentation() { + assert_eq!(normalize_text("fact \r\n\r\n"), "fact"); + assert_ne!(normalize_text(" code\n"), normalize_text("code\n")); + } + + #[cfg(unix)] + #[test] + fn file_memory_rejects_symlinked_owner_file() { + use std::os::unix::fs::symlink; + + let temp = test_tempdir("file-memory-symlink"); + let roots = fixture_roots(temp.path()); + fs::create_dir_all(&roots.legacy_home_root).unwrap(); + let memory_root = source_file_memory_root(&roots); + fs::create_dir_all(&memory_root).unwrap(); + let outside = roots.legacy_home_root.join("outside.md"); + fs::write(&outside, "outside").unwrap(); + symlink(&outside, memory_root.join("MEMORY.md")).unwrap(); + + assert!(matches!( + plan_file_memory(&roots), + Err(LegacyMigrationError::LinkedPath(_)) + )); + } + + #[cfg(windows)] + #[test] + fn file_memory_rejects_reparse_owner_file_when_supported() { + use std::os::windows::fs::symlink_file; + + let temp = test_tempdir("file-memory-reparse"); + let roots = fixture_roots(temp.path()); + fs::create_dir_all(&roots.legacy_home_root).unwrap(); + let memory_root = source_file_memory_root(&roots); + fs::create_dir_all(&memory_root).unwrap(); + let outside = roots.legacy_home_root.join("outside.md"); + fs::write(&outside, "outside").unwrap(); + if symlink_file(&outside, memory_root.join("MEMORY.md")).is_err() { + return; + } + + assert!(matches!( + plan_file_memory(&roots), + Err(LegacyMigrationError::LinkedPath(_)) + )); + } + + fn memory_selection() -> MigrationSelection { + MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::Memory]), + } + } + + fn memory_record(session_id: &str, raw_memory: &str, summary: &str) -> MemoryRecord { + MemoryRecord { + session_id: session_id.to_string(), + workspace_path: "C:\\fixture-workspace".to_string(), + rollout_path: format!("C:\\fixture-workspace\\sessions\\{session_id}"), + source_updated_at_unix_secs: 10, + raw_memory: raw_memory.to_string(), + rollout_summary: summary.to_string(), + rollout_slug: Some(format!("memory-{session_id}")), + generated_at_unix_secs: 11, + usage_count: 0, + last_usage_unix_secs: None, + selected_for_phase2: 0, + selected_for_phase2_source_updated_at: None, + } + } + + fn materialize_source_memory(roots: &MigrationRoots, with_wal_rows: bool) -> Connection { + let sql_path = roots.legacy_user_root.join("data/memories/memories.sql"); + let database_path = source_structured_memory_path(roots); + fs::create_dir_all(database_path.parent().unwrap()).unwrap(); + let connection = Connection::open(&database_path).unwrap(); + connection + .execute_batch(&fs::read_to_string(sql_path).unwrap()) + .unwrap(); + if with_wal_rows { + connection + .pragma_update(None, "journal_mode", "WAL") + .unwrap(); + connection + .pragma_update(None, "wal_autocheckpoint", 0) + .unwrap(); + for record in [ + memory_record( + "session-memory-import", + "Imported durable fact.", + "Imported summary.", + ), + memory_record( + "session-memory-duplicate", + "Repeated durable fact.\r\n", + "Repeated summary. ", + ), + memory_record( + "session-memory-wal", + "WAL-only durable fact.", + "WAL-only summary.", + ), + ] { + upsert_memory_record(&connection, &record, true).unwrap(); + } + assert!(PathBuf::from(format!("{}-wal", database_path.display())).exists()); + } + connection + } + + fn fixture_roots(root: &Path) -> MigrationRoots { + let legacy_user_root = root.join("legacy-user"); + MigrationRoots { + legacy_skills_root: legacy_user_root.join("skills"), + legacy_user_root, + legacy_home_root: root.join("legacy-home"), + legacy_ssh_root: root.join("legacy-ssh"), + target_user_root: root.join("target-user"), + target_home_root: root.join("target-home"), + target_skills_root: root.join("target-skills"), + target_ssh_root: root.join("target-ssh"), + } + } + + fn copy_fixture(roots: &MigrationRoots) { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../services/legacy-migration/tests/fixtures/v0.2.19"); + copy_directory(&fixture.join("user-root"), &roots.legacy_user_root).unwrap(); + copy_directory(&fixture.join("home"), &roots.legacy_home_root).unwrap(); + copy_directory(&fixture.join("ssh"), &roots.legacy_ssh_root).unwrap(); + } + + fn copy_directory(source: &Path, target: &Path) -> std::io::Result<()> { + fs::create_dir_all(target)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_directory(&source_path, &target_path)?; + } else { + fs::copy(source_path, target_path)?; + } + } + Ok(()) + } + + fn hash_source_roots(roots: &MigrationRoots) -> String { + let mut entries = Vec::new(); + for root in [ + &roots.legacy_user_root, + &roots.legacy_home_root, + &roots.legacy_ssh_root, + ] { + collect_source_files(root, root, &mut entries); + } + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let mut hasher = Sha256::new(); + for (path, bytes) in entries { + hasher.update(path.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update([0]); + hasher.update(bytes); + hasher.update([0]); + } + hex::encode(hasher.finalize()) + } + + fn collect_source_files(root: &Path, path: &Path, entries: &mut Vec<(PathBuf, Vec)>) { + if path.is_file() { + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with("-shm")) + { + return; + } + let mut bytes = Vec::new(); + fs::File::open(path) + .unwrap() + .read_to_end(&mut bytes) + .unwrap(); + entries.push((path.strip_prefix(root).unwrap().to_path_buf(), bytes)); + return; + } + let mut children = fs::read_dir(path) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + children.sort(); + for child in children { + collect_source_files(root, &child, entries); + } + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("E:/tmp")); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix(&format!("obfm-{}-", &label[..label.len().min(4)])) + .tempdir_in(root) + .unwrap() + } +} diff --git a/src/crates/assembly/core/src/legacy_migration/mod.rs b/src/crates/assembly/core/src/legacy_migration/mod.rs new file mode 100644 index 0000000000..4785cec44a --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/mod.rs @@ -0,0 +1,121 @@ +//! Product-owned legacy BitFun domain adapters. +//! +//! The generic service crate owns offline orchestration and filesystem safety; +//! this module binds each legacy reader and converter to the current product +//! domain model without moving those owners into the service layer. + +mod agent_coordination; +mod common; +mod extensions; +mod memory; +mod remote_connect; +mod remote_ssh; +mod settings; +mod workspace_sessions; + +use openbitfun_legacy_migration::{ + DomainContext, DomainScan, LegacyDomainAdapter, LegacyMigrationResult, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + FindingSeverity, MigrationDomainId, MigrationDomainResult, MigrationDomainState, + MigrationGroupId, MigrationSelection, ScanFinding, +}; + +pub fn adapters_for_groups(selection: &MigrationSelection) -> Vec> { + let selected = selection.expanded_domains(); + let mut adapters: Vec> = Vec::new(); + if selected.contains(&MigrationDomainId::Settings) { + adapters.push(Box::new(settings::SettingsAdapter)); + } + if selected.contains(&MigrationDomainId::Credentials) { + adapters.push(Box::new(settings::CredentialsAdapter)); + } + if selected.contains(&MigrationDomainId::Skills) { + adapters.push(Box::new(extensions::SkillsAdapter)); + } + if selected.contains(&MigrationDomainId::Miniapps) { + adapters.push(Box::new(extensions::MiniappsAdapter)); + } + if selected.contains(&MigrationDomainId::Agents) { + adapters.push(Box::new(extensions::AgentsAdapter)); + } + if selected.contains(&MigrationDomainId::WorkspaceSessions) { + adapters.push(Box::new(workspace_sessions::WorkspaceSessionsAdapter)); + } + if selected.contains(&MigrationDomainId::AgentCoordination) { + adapters.push(Box::new(agent_coordination::AgentCoordinationAdapter)); + } + if selected.contains(&MigrationDomainId::StructuredMemory) { + adapters.push(Box::new(memory::StructuredMemoryAdapter)); + } + if selected.contains(&MigrationDomainId::FileMemory) { + adapters.push(Box::new(memory::FileMemoryAdapter)); + } + if selected.contains(&MigrationDomainId::RemoteConnectDevices) { + adapters.push(Box::new(remote_connect::RemoteConnectAdapter)); + } + if selected.contains(&MigrationDomainId::RemoteSsh) { + adapters.push(Box::new(remote_ssh::RemoteSshAdapter)); + } + if selected.contains(&MigrationDomainId::CrossReferenceRepair) { + adapters.push(Box::new(CrossReferenceAdapter)); + } + adapters +} + +struct CrossReferenceAdapter; + +impl LegacyDomainAdapter for CrossReferenceAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::CrossReferenceRepair + } + + fn scan(&self, _roots: &MigrationRoots) -> LegacyMigrationResult { + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: "cross_reference_validation_enabled".to_string(), + severity: FindingSeverity::Info, + migratable: true, + detail: "Selected owner adapters will be checked after their atomic commits." + .to_string(), + ..ScanFinding::default() + }, + conflicts: Vec::new(), + target_schema: Some("openbitfun.cross-references.current".to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, _context: &DomainContext<'_>) -> LegacyMigrationResult { + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + validate_selected_cross_references(context) + } + + fn commit(&self, _context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + validate_selected_cross_references(context) + } +} + +fn validate_selected_cross_references(context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + if context + .plan + .selection + .groups + .contains(&MigrationGroupId::WorkspacesSessionsAndTasks) + { + agent_coordination::validate_committed_coordination_cross_references(context)?; + } + Ok(()) +} diff --git a/src/crates/assembly/core/src/legacy_migration/remote_connect.rs b/src/crates/assembly/core/src/legacy_migration/remote_connect.rs new file mode 100644 index 0000000000..11d8a3026a --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/remote_connect.rs @@ -0,0 +1,1515 @@ +use super::common::{ + backup_domain_dir, backup_file_once, io_error, read_bounded_json, read_optional_bounded_json, + relative_display, restore_unverified_file, stage_domain_dir, validate_regular_file, + MAX_JSON_BYTES, +}; +use openbitfun_legacy_migration::{ + atomic_write_json, DomainContext, DomainScan, LegacyDomainAdapter, LegacyMigrationError, + LegacyMigrationResult, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + ConflictResolution, FindingSeverity, MigrationConflict, MigrationDiagnostic, MigrationDomainId, + MigrationDomainResult, MigrationDomainState, ScanFinding, +}; +use openbitfun_services_integrations::remote_persistence as owner; +use owner::{ + AccountHintRecord, AccountSessionRecord, AccountSyncStateRecord, BotChatStateRecord, + BotConfigRecord, BotPersistenceRecord, LegacyAccountSessionKeyDomains, MachineBinding, + RemoteConnectFormStateRecord, SavedBotConnectionRecord, SettingsCursorRecord, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const DOMAIN_DIR: &str = "remote-connect"; +const SOURCE_SCHEMA: &str = "bitfun.remote-connect.v0.2.19"; +const TARGET_SCHEMA: &str = "openbitfun.remote-connect.current"; +const MAX_REMOTE_FILES: usize = 4_096; +const MAX_SECRET_BYTES: u64 = 16 * 1024 * 1024; +const LEGACY_ACCOUNT_SESSION_KEY_DOMAINS: LegacyAccountSessionKeyDomains<'static> = + LegacyAccountSessionKeyDomains { + v1: b"BitFun::session_store::v1", + v2: b"|BitFun::session_store::v2|", + }; + +pub(crate) struct RemoteConnectAdapter; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum BotSourceKind { + Canonical, + Fallback, + None, + UnresolvedBackup, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemoteConnectManifest { + source_files: BTreeMap, + target_before: BTreeMap>, + bot_source_kind: BotSourceKind, + target_bot_unresolved: bool, + imported: u64, + skipped: u64, + conflicts: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct RemoteConnectReceipt { + manifest_digest: String, + completed: bool, + post_files: BTreeMap>, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct RemoteConnectOutcome { + imported: u64, + skipped: u64, + conflicts: u64, + warnings: Vec, + requires_reauthentication: Vec, +} + +#[derive(Default)] +struct RemoteConnectState { + files: BTreeMap, + device: Option, + account_session_present: bool, + account_hint: Option, + sync_states: BTreeMap, + settings_cursors: BTreeMap, + bot: Option, + bot_source_kind: BotSourceKind, + bot_unresolved: bool, + weixin_sync: BTreeMap, + weixin_tokens: BTreeMap>, +} + +impl Default for BotSourceKind { + fn default() -> Self { + Self::None + } +} + +impl LegacyDomainAdapter for RemoteConnectAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::RemoteConnectDevices + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let source = read_state(&roots.legacy_home_root, true)?; + let target = read_state(&roots.target_home_root, false)?; + let preview = preview(&source, &target); + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: if source.files.is_empty() { + "legacy_remote_connect_absent".to_string() + } else { + "legacy_remote_connect_supported".to_string() + }, + severity: if preview.conflicts.is_empty() { + FindingSeverity::Info + } else { + FindingSeverity::Warning + }, + entity_count: source_entity_count(&source), + logical_bytes: total_bytes(&roots.legacy_home_root, source.files.keys())?, + source_schema: Some(SOURCE_SCHEMA.to_string()), + migratable: !source.bot_unresolved || source.files.len() > 1, + detail: "Legacy Remote Connect identity, account, sync, and bot stores were inspected without exposing credentials.".to_string(), + }, + conflicts: preview.conflicts, + target_schema: Some(TARGET_SCHEMA.to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + reset_stage(context)?; + let source = read_state(&context.roots.legacy_home_root, true)?; + let target = read_state(&context.roots.target_home_root, false)?; + let preview = preview(&source, &target); + let target_before = target_candidates(&source, &target) + .into_iter() + .map(|relative| { + let digest = target.files.get(&relative).cloned(); + (relative, digest) + }) + .collect(); + let manifest = RemoteConnectManifest { + source_files: source.files, + target_before, + bot_source_kind: source.bot_source_kind, + target_bot_unresolved: target.bot_unresolved, + imported: preview.imported, + skipped: preview.skipped, + conflicts: preview.conflicts.len() as u64, + }; + fs::create_dir_all(stage_domain_dir(context, DOMAIN_DIR)) + .map_err(|error| io_error(&stage_domain_dir(context, DOMAIN_DIR), error))?; + atomic_write_json(&manifest_path(context), &manifest)?; + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported: manifest.imported, + skipped: manifest.skipped, + conflicts: manifest.conflicts, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context)?; + let source = read_state(&context.roots.legacy_home_root, true)?; + if source.files != manifest.source_files + || source.bot_source_kind != manifest.bot_source_kind + { + return Err(LegacyMigrationError::InvalidRequest( + "legacy Remote Connect inputs changed after staging".to_string(), + )); + } + if let Some(receipt) = read_optional_bounded_json::( + &context.layout.stage_root(), + &receipt_path(context), + )? { + if receipt.manifest_digest != json_digest(&manifest)? { + return Err(LegacyMigrationError::InvalidRequest( + "Remote Connect commit receipt does not match its staged manifest".to_string(), + )); + } + return Ok(()); + } + let target = read_state(&context.roots.target_home_root, false)?; + if current_candidates( + &context.roots.target_home_root, + manifest.target_before.keys(), + )? != manifest.target_before + || target.bot_unresolved != manifest.target_bot_unresolved + { + return Err(LegacyMigrationError::InvalidRequest( + "current Remote Connect data changed after staging".to_string(), + )); + } + Ok(()) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context)?; + let manifest_digest = json_digest(&manifest)?; + let existing_receipt = read_optional_bounded_json::( + &context.layout.stage_root(), + &receipt_path(context), + )?; + if let Some(receipt) = &existing_receipt { + if receipt.manifest_digest != manifest_digest { + return Err(LegacyMigrationError::InvalidRequest( + "Remote Connect commit receipt does not match its manifest".to_string(), + )); + } + if receipt.completed + && current_candidates(&context.roots.target_home_root, receipt.post_files.keys())? + == receipt.post_files + { + return Ok(()); + } + } else { + let current = current_candidates( + &context.roots.target_home_root, + manifest.target_before.keys(), + )?; + if current != manifest.target_before { + return Err(LegacyMigrationError::InvalidRequest( + "current Remote Connect data changed before commit".to_string(), + )); + } + backup_targets(context, &manifest.target_before)?; + atomic_write_json( + &receipt_path(context), + &RemoteConnectReceipt { + manifest_digest: manifest_digest.clone(), + completed: false, + post_files: BTreeMap::new(), + }, + )?; + } + + let source = read_state(&context.roots.legacy_home_root, true)?; + if source.files != manifest.source_files { + return Err(LegacyMigrationError::InvalidRequest( + "legacy Remote Connect inputs changed during commit".to_string(), + )); + } + let original_root = backup_domain_dir(context, DOMAIN_DIR); + let original = read_state(&original_root, false)?; + let mut outcome = RemoteConnectOutcome { + imported: manifest.imported, + skipped: manifest.skipped, + conflicts: manifest.conflicts, + ..RemoteConnectOutcome::default() + }; + if manifest.conflicts > 0 { + outcome.warnings.push(warning( + "remote_connect_conflicts_require_review", + "One or more current Remote Connect records took priority or require owner recovery.", + )); + } + if !manifest.source_files.is_empty() { + apply_merge(context, &source, &original, &mut outcome)?; + } + + let post_files = current_candidates( + &context.roots.target_home_root, + manifest.target_before.keys(), + )?; + atomic_write_json(&outcome_path(context), &outcome)?; + atomic_write_json( + &receipt_path(context), + &RemoteConnectReceipt { + manifest_digest, + completed: true, + post_files, + }, + ) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let receipt: RemoteConnectReceipt = + read_bounded_json(&context.layout.stage_root(), &receipt_path(context))?; + if !receipt.completed + || current_candidates(&context.roots.target_home_root, receipt.post_files.keys())? + != receipt.post_files + { + return Err(LegacyMigrationError::InvalidRequest( + "current Remote Connect owner did not retain the committed data".to_string(), + )); + } + // Strict owner readers validate every installed active store. The + // encrypted session is also decrypted here, never reported. + let state = read_state(&context.roots.target_home_root, false)?; + if state.account_session_present { + owner::read_current_account_session( + &context.roots.target_home_root, + &MachineBinding::current(), + ) + .map_err(owner_error)?; + } + Ok(()) + } + + fn finalize_result( + &self, + context: &DomainContext<'_>, + staged: &MigrationDomainResult, + ) -> LegacyMigrationResult { + let outcome: RemoteConnectOutcome = + read_bounded_json(&context.layout.stage_root(), &outcome_path(context))?; + let mut result = staged.clone(); + result.imported = outcome.imported; + result.skipped = outcome.skipped; + result.conflicts = outcome.conflicts; + result.warnings = outcome.warnings; + result.requires_reauthentication = outcome.requires_reauthentication; + Ok(result) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let Some(manifest) = read_optional_bounded_json::( + &context.layout.stage_root(), + &manifest_path(context), + )? + else { + return Ok(()); + }; + let backup_root = backup_domain_dir(context, DOMAIN_DIR); + for (relative, digest) in &manifest.target_before { + let target = context.roots.target_home_root.join(relative); + let backup = backup_root.join(relative); + restore_unverified_file(&target, &backup, digest.is_some())?; + } + Ok(()) + } +} + +struct Preview { + imported: u64, + skipped: u64, + conflicts: Vec, +} + +fn preview(source: &RemoteConnectState, target: &RemoteConnectState) -> Preview { + let mut imported = 0; + let mut skipped = 0; + let mut conflicts = Vec::new(); + if source.device.is_some() { + if target.device.is_some() { + skipped += 1; + conflicts.push(target_wins( + "device_identity_target_wins", + "legacy device identity", + "current device identity", + )); + } else { + imported += 1; + } + } + if source.account_session_present { + if target.account_session_present { + skipped += 1; + conflicts.push(target_wins( + "account_session_target_wins", + "legacy account session", + "current account session", + )); + } else { + imported += 1; + } + } + if source.account_hint.is_some() { + imported += 1; + } + for connection in source + .bot + .as_ref() + .into_iter() + .flat_map(|bot| &bot.connections) + { + if target.bot.as_ref().is_some_and(|bot| { + bot.connections + .iter() + .any(|candidate| candidate.bot_type == connection.bot_type) + }) { + skipped += 1; + conflicts.push(target_wins( + "bot_connection_target_wins", + "legacy bot connection", + "current bot connection", + )); + } else { + imported += 1; + } + } + if source.bot_unresolved { + conflicts.push(MigrationConflict { + domain: MigrationDomainId::RemoteConnectDevices, + code: "legacy_bot_transaction_unresolved".to_string(), + source_summary: "canonical bot persistence is missing while its backup exists" + .to_string(), + target_summary: "fallback bot persistence was not opened".to_string(), + resolution: ConflictResolution::RequiresUserAction, + }); + } + if target.bot_unresolved { + conflicts.push(MigrationConflict { + domain: MigrationDomainId::RemoteConnectDevices, + code: "target_bot_transaction_unresolved".to_string(), + source_summary: "legacy bot persistence was left unchanged".to_string(), + target_summary: "current bot persistence has an unresolved replacement backup" + .to_string(), + resolution: ConflictResolution::RequiresUserAction, + }); + } + Preview { + imported, + skipped, + conflicts, + } +} + +fn target_wins(code: &str, source: &str, target: &str) -> MigrationConflict { + MigrationConflict { + domain: MigrationDomainId::RemoteConnectDevices, + code: code.to_string(), + source_summary: source.to_string(), + target_summary: target.to_string(), + resolution: ConflictResolution::TargetWins, + } +} + +fn apply_merge( + context: &DomainContext<'_>, + source: &RemoteConnectState, + target: &RemoteConnectState, + outcome: &mut RemoteConnectOutcome, +) -> LegacyMigrationResult<()> { + let target_root = &context.roots.target_home_root; + if target.device.is_none() { + if let Some(device) = &source.device { + owner::write_device_identity(&target_root.join("device_identity.json"), device) + .map_err(owner_error)?; + } + } + if let Some(source_hint) = &source.account_hint { + let mut merged = target.account_hint.clone().unwrap_or_default(); + fill_empty(&mut merged.username, &source_hint.username); + fill_empty(&mut merged.relay_url, &source_hint.relay_url); + owner::write_account_hint(&target_root.join("account_hint.json"), &merged) + .map_err(owner_error)?; + } + + let binding = MachineBinding::current(); + let source_session = if source.account_session_present { + match owner::read_legacy_account_session( + &context.roots.legacy_home_root, + &binding, + LEGACY_ACCOUNT_SESSION_KEY_DOMAINS, + ) { + Ok(value) => value, + Err(_) => { + warn_reauthentication( + outcome, + "remote_connect_account", + "legacy_account_session_unavailable", + "The legacy Remote Connect account could not be securely transferred.", + ); + None + } + } + } else { + None + }; + let target_session = if target.account_session_present { + match owner::read_current_account_session(&backup_domain_dir(context, DOMAIN_DIR), &binding) + { + Ok(value) => value, + Err(_) => { + warn_reauthentication( + outcome, + "remote_connect_account", + "target_account_session_unavailable", + "The existing Remote Connect account session needs authentication repair.", + ); + None + } + } + } else { + None + }; + let effective_session = if target.account_session_present { + target_session.as_ref() + } else if let Some(session) = &source_session { + owner::write_current_account_session(target_root, &binding, session) + .map_err(owner_error)?; + Some(session) + } else { + None + }; + if let (Some(source_session), Some(effective_session)) = + (source_session.as_ref(), effective_session) + { + if same_account(source_session, effective_session) { + merge_account_sync(context, source_session, target, outcome)?; + } else { + outcome.skipped = outcome + .skipped + .saturating_add(source.sync_states.len() as u64); + outcome.warnings.push(warning( + "account_sync_different_account_skipped", + "Legacy account sync cursors were not applied to a different current account.", + )); + } + } + + let merged_bot = match (&source.bot, &target.bot) { + (_, _) if target.bot_unresolved => { + outcome.warnings.push(warning( + "target_bot_transaction_unresolved", + "Bot persistence was left unchanged because the current owner has an unresolved replacement backup.", + )); + None + } + (Some(source), Some(target)) => Some(merge_bot(source, target, true)), + (Some(source), None) => Some(source.clone()), + (None, Some(target)) => Some(target.clone()), + (None, None) => None, + }; + if let Some(bot) = &merged_bot { + owner::write_bot_persistence(&target_root.join("remote_connect_persistence.json"), bot) + .map_err(owner_error)?; + merge_weixin_auxiliary(context, source, target, bot)?; + } + if source.bot_unresolved { + outcome.warnings.push(warning( + "legacy_bot_transaction_unresolved", + "Fallback bot persistence was not restored because a canonical backup indicates an unresolved transaction.", + )); + } + Ok(()) +} + +fn merge_account_sync( + context: &DomainContext<'_>, + session: &AccountSessionRecord, + target: &RemoteConnectState, + outcome: &mut RemoteConnectOutcome, +) -> LegacyMigrationResult<()> { + let Some(component) = owner::safe_account_file_component(&session.user_id) else { + outcome.warnings.push(warning( + "account_sync_invalid_user_id", + "Account sync cursors were skipped because their safe owner filename could not be resolved.", + )); + return Ok(()); + }; + let state_name = format!("{component}.json"); + let settings_name = format!("{component}.settings.json"); + let source_root = context.roots.legacy_home_root.join("account_sync"); + let target_root = context.roots.target_home_root.join("account_sync"); + if let Some(source_state) = strict_sync_state( + &source_root.join(&state_name), + &context.roots.legacy_home_root, + )? { + let merged = merge_sync_state( + source_state, + target + .sync_states + .get(&state_name) + .cloned() + .unwrap_or_default(), + ); + owner::write_account_sync_state(&target_root.join(&state_name), &merged) + .map_err(owner_error)?; + outcome.imported = outcome.imported.saturating_add(1); + } + if let Some(source_cursor) = strict_settings_cursor( + &source_root.join(&settings_name), + &context.roots.legacy_home_root, + )? { + let merged = choose_settings_cursor( + source_cursor, + target.settings_cursors.get(&settings_name).cloned(), + ); + owner::write_settings_cursor(&target_root.join(&settings_name), &merged) + .map_err(owner_error)?; + outcome.imported = outcome.imported.saturating_add(1); + } + Ok(()) +} + +fn merge_sync_state( + source: AccountSyncStateRecord, + mut target: AccountSyncStateRecord, +) -> AccountSyncStateRecord { + target.last_session_since = target.last_session_since.max(source.last_session_since); + for (session_id, hash) in source.uploaded_hashes { + target.uploaded_hashes.entry(session_id).or_insert(hash); + } + target +} + +fn choose_settings_cursor( + source: SettingsCursorRecord, + target: Option, +) -> SettingsCursorRecord { + target + .filter(|cursor| cursor.version >= source.version) + .unwrap_or(source) +} + +fn same_account(left: &AccountSessionRecord, right: &AccountSessionRecord) -> bool { + left.user_id == right.user_id + && normalized_url(&left.relay_url) == normalized_url(&right.relay_url) +} + +fn normalized_url(value: &str) -> &str { + value.trim().trim_end_matches('/') +} + +fn merge_bot( + source: &BotPersistenceRecord, + target: &BotPersistenceRecord, + target_present: bool, +) -> BotPersistenceRecord { + let mut merged = target.clone(); + for source_connection in &source.connections { + if let Some(target_connection) = merged + .connections + .iter_mut() + .find(|candidate| candidate.bot_type == source_connection.bot_type) + { + merge_bot_connection(source_connection, target_connection); + } else { + merged.connections.push(source_connection.clone()); + } + } + merge_form_state(&source.form_state, &mut merged.form_state); + if !target_present { + merged.verbose_mode = source.verbose_mode; + } + merged +} + +fn merge_bot_connection(source: &SavedBotConnectionRecord, target: &mut SavedBotConnectionRecord) { + fill_empty(&mut target.chat_id, &source.chat_id); + if target.connected_at == 0 { + target.connected_at = source.connected_at; + } + merge_bot_config(&source.config, &mut target.config); + merge_chat_state(&source.chat_state, &mut target.chat_state); +} + +fn merge_bot_config(source: &BotConfigRecord, target: &mut BotConfigRecord) { + match (source, target) { + ( + BotConfigRecord::Feishu { app_id, app_secret }, + BotConfigRecord::Feishu { + app_id: target_id, + app_secret: target_secret, + }, + ) => { + fill_empty(target_id, app_id); + fill_empty(target_secret, app_secret); + } + ( + BotConfigRecord::Telegram { bot_token }, + BotConfigRecord::Telegram { + bot_token: target_token, + }, + ) => fill_empty(target_token, bot_token), + ( + BotConfigRecord::Weixin { + ilink_token, + base_url, + bot_account_id, + }, + BotConfigRecord::Weixin { + ilink_token: target_token, + base_url: target_url, + bot_account_id: target_account, + }, + ) => { + fill_empty(target_token, ilink_token); + fill_empty(target_url, base_url); + fill_empty(target_account, bot_account_id); + } + _ => {} + } +} + +fn merge_chat_state(source: &BotChatStateRecord, target: &mut BotChatStateRecord) { + fill_empty(&mut target.chat_id, &source.chat_id); + let mut imported_source_context = false; + if target.current_workspace.is_none() { + target.current_workspace = source.current_workspace.clone(); + imported_source_context |= target.current_workspace.is_some(); + } + if target.current_assistant.is_none() { + target.current_assistant = source.current_assistant.clone(); + imported_source_context |= target.current_assistant.is_some(); + } + if target.current_assistant_name.is_none() { + target.current_assistant_name = source.current_assistant_name.clone(); + } + if target.current_session_id.is_none() { + target.current_session_id = source.current_session_id.clone(); + imported_source_context |= target.current_session_id.is_some(); + } + if imported_source_context { + target.account_remote_context |= source.account_remote_context; + } +} + +fn merge_form_state( + source: &RemoteConnectFormStateRecord, + target: &mut RemoteConnectFormStateRecord, +) { + fill_empty(&mut target.custom_server_url, &source.custom_server_url); + fill_empty(&mut target.telegram_bot_token, &source.telegram_bot_token); + fill_empty(&mut target.feishu_app_id, &source.feishu_app_id); + fill_empty(&mut target.feishu_app_secret, &source.feishu_app_secret); + fill_empty(&mut target.weixin_ilink_token, &source.weixin_ilink_token); + fill_empty(&mut target.weixin_base_url, &source.weixin_base_url); + fill_empty( + &mut target.weixin_bot_account_id, + &source.weixin_bot_account_id, + ); +} + +fn fill_empty(target: &mut String, source: &str) { + if target.trim().is_empty() && !source.trim().is_empty() { + *target = source.to_string(); + } +} + +fn merge_weixin_auxiliary( + context: &DomainContext<'_>, + source: &RemoteConnectState, + target: &RemoteConnectState, + merged: &BotPersistenceRecord, +) -> LegacyMigrationResult<()> { + let active_ids = active_weixin_ids(merged)?; + for account_id in active_ids { + if let Some(source_buf) = source.weixin_sync.get(&account_id) { + let target_path = weixin_sync_path(&context.roots.target_home_root, &account_id); + if !target.files.contains_key(&home_relative( + &context.roots.target_home_root, + &target_path, + )) { + owner::write_weixin_sync_buffer(&target_path, source_buf).map_err(owner_error)?; + } + } + if let Some(source_tokens) = source.weixin_tokens.get(&account_id) { + let mut tokens = target + .weixin_tokens + .get(&account_id) + .cloned() + .unwrap_or_default(); + for (peer, token) in source_tokens { + tokens.entry(peer.clone()).or_insert_with(|| token.clone()); + } + owner::write_context_tokens( + &weixin_tokens_path(&context.roots.target_home_root, &account_id), + &tokens, + ) + .map_err(owner_error)?; + } + } + Ok(()) +} + +fn read_state(root: &Path, legacy: bool) -> LegacyMigrationResult { + let mut state = RemoteConnectState::default(); + let device_path = root.join("device_identity.json"); + if existing_regular(root, &device_path, MAX_JSON_BYTES)? { + state.device = owner::read_device_identity(&device_path).map_err(owner_error)?; + record_file(root, &device_path, &mut state.files)?; + } + let hint_path = root.join("account_hint.json"); + if existing_regular(root, &hint_path, MAX_JSON_BYTES)? { + state.account_hint = owner::read_account_hint(&hint_path).map_err(owner_error)?; + record_file(root, &hint_path, &mut state.files)?; + } + for name in ["account_session.enc", "account_session.key"] { + let path = root.join(name); + if existing_regular(root, &path, MAX_SECRET_BYTES)? { + record_file(root, &path, &mut state.files)?; + } + } + state.account_session_present = state.files.contains_key("account_session.enc"); + + let sync_root = root.join("account_sync"); + if existing_directory(root, &sync_root)? { + let paths = owner::account_sync_paths(&sync_root).map_err(owner_error)?; + if paths.len() > MAX_REMOTE_FILES { + return Err(LegacyMigrationError::ResourceLimit( + "account sync file count exceeds the migration limit".to_string(), + )); + } + for path in paths { + existing_regular(root, &path, MAX_JSON_BYTES)?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "account sync filename is not UTF-8".to_string(), + ) + })? + .to_string(); + if name.ends_with(".settings.json") { + let value = owner::read_settings_cursor(&path) + .map_err(owner_error)? + .ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "account settings cursor disappeared".to_string(), + ) + })?; + state.settings_cursors.insert(name, value); + } else { + let value = owner::read_account_sync_state(&path) + .map_err(owner_error)? + .ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "account sync state disappeared".to_string(), + ) + })?; + state.sync_states.insert(name, value); + } + record_file(root, &path, &mut state.files)?; + } + } + + let canonical = root.join("remote_connect_persistence.json"); + let backup = root.join("remote_connect_persistence.json.bak"); + let fallback = root.join("bot_connections.json"); + let canonical_exists = existing_regular(root, &canonical, MAX_JSON_BYTES)?; + let backup_exists = existing_regular(root, &backup, MAX_JSON_BYTES)?; + let fallback_exists = existing_regular(root, &fallback, MAX_JSON_BYTES)?; + for path in [&canonical, &backup, &fallback] { + if path.exists() { + record_file(root, path, &mut state.files)?; + } + } + if canonical_exists { + state.bot = owner::read_bot_persistence(&canonical).map_err(owner_error)?; + state.bot_source_kind = BotSourceKind::Canonical; + } else if backup_exists { + state.bot_unresolved = true; + state.bot_source_kind = BotSourceKind::UnresolvedBackup; + } else if legacy && fallback_exists { + state.bot = owner::read_bot_persistence(&fallback).map_err(owner_error)?; + state.bot_source_kind = BotSourceKind::Fallback; + } + + if let Some(bot) = &state.bot { + for account_id in active_weixin_ids(bot)? { + let sync_path = weixin_sync_path(root, &account_id); + if existing_regular(root, &sync_path, MAX_SECRET_BYTES)? { + if let Some(value) = + owner::read_weixin_sync_buffer(&sync_path).map_err(owner_error)? + { + state.weixin_sync.insert(account_id.clone(), value); + } + record_file(root, &sync_path, &mut state.files)?; + } + let token_path = weixin_tokens_path(root, &account_id); + if existing_regular(root, &token_path, MAX_JSON_BYTES)? { + if let Some(value) = owner::read_context_tokens(&token_path).map_err(owner_error)? { + state.weixin_tokens.insert(account_id.clone(), value); + } + record_file(root, &token_path, &mut state.files)?; + } + } + } + Ok(state) +} + +fn active_weixin_ids(bot: &BotPersistenceRecord) -> LegacyMigrationResult> { + let mut ids = BTreeSet::new(); + for connection in &bot.connections { + if let BotConfigRecord::Weixin { bot_account_id, .. } = &connection.config { + if bot_account_id.is_empty() { + continue; + } + if !owner::is_safe_weixin_account_id(bot_account_id) { + return Err(LegacyMigrationError::InvalidRequest( + "Weixin bot account id is not a safe persistence component".to_string(), + )); + } + ids.insert(bot_account_id.clone()); + } + } + Ok(ids) +} + +fn strict_sync_state( + path: &Path, + root: &Path, +) -> LegacyMigrationResult> { + if !existing_regular(root, path, MAX_JSON_BYTES)? { + return Ok(None); + } + owner::read_account_sync_state(path).map_err(owner_error) +} + +fn strict_settings_cursor( + path: &Path, + root: &Path, +) -> LegacyMigrationResult> { + if !existing_regular(root, path, MAX_JSON_BYTES)? { + return Ok(None); + } + owner::read_settings_cursor(path).map_err(owner_error) +} + +fn target_candidates(source: &RemoteConnectState, target: &RemoteConnectState) -> BTreeSet { + let mut paths = target.files.keys().cloned().collect::>(); + paths.extend([ + "device_identity.json".to_string(), + "account_hint.json".to_string(), + "account_session.enc".to_string(), + "account_session.key".to_string(), + "remote_connect_persistence.json".to_string(), + ]); + for name in source + .sync_states + .keys() + .chain(source.settings_cursors.keys()) + { + paths.insert(format!("account_sync/{name}")); + } + for account_id in source.weixin_sync.keys().chain(source.weixin_tokens.keys()) { + paths.insert(format!("weixin/{account_id}_get_updates_buf.txt")); + paths.insert(format!("weixin/{account_id}_context_tokens.json")); + } + paths +} + +fn current_candidates<'a>( + root: &Path, + candidates: impl IntoIterator, +) -> LegacyMigrationResult>> { + let mut values = BTreeMap::new(); + for relative in candidates { + let path = root.join(relative); + let digest = match fs::symlink_metadata(&path) { + Ok(_) => { + validate_regular_file(root, &path)?; + Some(file_digest(&path)?) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(io_error(&path, error)), + }; + values.insert(relative.clone(), digest); + } + Ok(values) +} + +fn backup_targets( + context: &DomainContext<'_>, + target_before: &BTreeMap>, +) -> LegacyMigrationResult<()> { + let backup_root = backup_domain_dir(context, DOMAIN_DIR); + for (relative, digest) in target_before { + if digest.is_some() { + let target = context.roots.target_home_root.join(relative); + let backup = backup_root.join(relative); + if is_secret_relative(relative) { + if !backup.exists() { + let bytes = fs::read(&target).map_err(|error| io_error(&target, error))?; + owner::write_private_bytes(&backup, &bytes).map_err(owner_error)?; + } + } else { + backup_file_once(&target, &backup)?; + } + } + } + Ok(()) +} + +fn is_secret_relative(relative: &str) -> bool { + matches!( + relative, + "account_session.enc" + | "account_session.key" + | "account_hint.json" + | "remote_connect_persistence.json" + | "remote_connect_persistence.json.bak" + | "bot_connections.json" + ) || relative.starts_with("weixin/") +} + +fn existing_regular(root: &Path, path: &Path, max_bytes: u64) -> LegacyMigrationResult { + match fs::symlink_metadata(path) { + Ok(_) => { + validate_regular_file(root, path)?; + let bytes = fs::metadata(path) + .map_err(|error| io_error(path, error))? + .len(); + if bytes > max_bytes { + return Err(LegacyMigrationError::ResourceLimit(format!( + "Remote Connect file exceeds {max_bytes} bytes: {}", + relative_display(root, path) + ))); + } + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(io_error(path, error)), + } +} + +fn existing_directory(root: &Path, path: &Path) -> LegacyMigrationResult { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "expected a regular Remote Connect directory at {}", + relative_display(root, path) + ))); + } + let canonical_root = fs::canonicalize(root).map_err(|error| io_error(root, error))?; + let canonical_path = fs::canonicalize(path).map_err(|error| io_error(path, error))?; + if !canonical_path.starts_with(canonical_root) { + return Err(LegacyMigrationError::PathEscape(path.to_path_buf())); + } + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(io_error(path, error)), + } +} + +fn record_file( + root: &Path, + path: &Path, + files: &mut BTreeMap, +) -> LegacyMigrationResult<()> { + files.insert(home_relative(root, path), file_digest(path)?); + Ok(()) +} + +fn home_relative(root: &Path, path: &Path) -> String { + relative_display(root, path) +} + +fn file_digest(path: &Path) -> LegacyMigrationResult { + let bytes = fs::read(path).map_err(|error| io_error(path, error))?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} + +fn json_digest(value: &impl Serialize) -> LegacyMigrationResult { + let bytes = serde_json::to_vec(value).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("serialize Remote Connect manifest: {error}")) + })?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} + +fn total_bytes<'a>( + root: &Path, + paths: impl IntoIterator, +) -> LegacyMigrationResult { + let mut total = 0u64; + for relative in paths { + let path = root.join(relative); + total = total.saturating_add( + fs::metadata(&path) + .map_err(|error| io_error(&path, error))? + .len(), + ); + } + Ok(total) +} + +fn source_entity_count(state: &RemoteConnectState) -> u64 { + u64::from(state.device.is_some()) + + u64::from(state.account_session_present) + + u64::from(state.account_hint.is_some()) + + state.sync_states.len() as u64 + + state.settings_cursors.len() as u64 + + state + .bot + .as_ref() + .map_or(0, |bot| bot.connections.len() as u64) + + state.weixin_sync.len() as u64 + + state.weixin_tokens.len() as u64 +} + +fn weixin_sync_path(root: &Path, account_id: &str) -> PathBuf { + root.join("weixin") + .join(format!("{account_id}_get_updates_buf.txt")) +} + +fn weixin_tokens_path(root: &Path, account_id: &str) -> PathBuf { + root.join("weixin") + .join(format!("{account_id}_context_tokens.json")) +} + +fn warn_reauthentication( + outcome: &mut RemoteConnectOutcome, + identifier: &str, + code: &str, + message: &str, +) { + outcome + .requires_reauthentication + .push(identifier.to_string()); + outcome.warnings.push(warning(code, message)); +} + +fn warning(code: &str, message: &str) -> MigrationDiagnostic { + MigrationDiagnostic { + code: code.to_string(), + severity: FindingSeverity::Warning, + domain: Some(MigrationDomainId::RemoteConnectDevices), + message: message.to_string(), + action: Some( + "Review the item in Data Migrator and authenticate again if needed".to_string(), + ), + ..MigrationDiagnostic::default() + } +} + +fn owner_error(error: anyhow::Error) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!( + "Remote Connect persistence owner rejected the data: {error:#}" + )) +} + +fn manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, DOMAIN_DIR).join("manifest.json") +} + +fn receipt_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, DOMAIN_DIR).join("commit-receipt.json") +} + +fn outcome_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, DOMAIN_DIR).join("outcome.json") +} + +fn read_manifest(context: &DomainContext<'_>) -> LegacyMigrationResult { + read_bounded_json(&context.layout.stage_root(), &manifest_path(context)) +} + +fn reset_stage(context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let path = stage_domain_dir(context, DOMAIN_DIR); + match fs::remove_dir_all(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error(&path, error)), + } + fs::create_dir_all(&path).map_err(|error| io_error(&path, error)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::legacy_migration::adapters_for_groups; + use openbitfun_legacy_migration::{ + probe_legacy_source, CancellationToken, CrashInjector, CrashPoint, LegacyMigrationError, + MigrationEngine, NoCrashInjection, ProbeLimits, + }; + use openbitfun_product_domains::legacy_migration::{ + MigrationGroupId, MigrationRunStatus, MigrationSelection, + }; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[test] + fn remote_connect_merge_preserves_target_identity_and_remote_bot_context() { + let temp = test_tempdir("merge"); + let roots = fixture_roots(temp.path()); + seed_probe_source(&roots); + fs::create_dir_all(&roots.legacy_home_root).unwrap(); + fs::create_dir_all(&roots.target_home_root).unwrap(); + let source_device = owner::DeviceIdentityRecord { + device_id: "11111111111111111111111111111111".to_string(), + device_name: "legacy-device".to_string(), + mac_address: "02:00:00:00:00:11".to_string(), + }; + let target_device = owner::DeviceIdentityRecord { + device_id: "22222222222222222222222222222222".to_string(), + device_name: "current-device".to_string(), + mac_address: "02:00:00:00:00:22".to_string(), + }; + owner::write_device_identity( + &roots.legacy_home_root.join("device_identity.json"), + &source_device, + ) + .unwrap(); + owner::write_device_identity( + &roots.target_home_root.join("device_identity.json"), + &target_device, + ) + .unwrap(); + let source_bot = bot_fixture("legacy-secret", Some("/srv/legacy"), true); + let target_bot = bot_fixture("current-secret", None, false); + owner::write_bot_persistence( + &roots + .legacy_home_root + .join("remote_connect_persistence.json"), + &source_bot, + ) + .unwrap(); + owner::write_bot_persistence( + &roots + .target_home_root + .join("remote_connect_persistence.json"), + &target_bot, + ) + .unwrap(); + // A syntactically valid encrypted envelope with the wrong key proves + // credential failure is reported without blocking identity/bot data. + fs::write( + roots.legacy_home_root.join("account_session.enc"), + "AAAAAAAAAAAAAAAAAAAA", + ) + .unwrap(); + fs::write( + roots.legacy_home_root.join("account_session.key"), + [0u8; 32], + ) + .unwrap(); + let before = source_hashes(&roots.legacy_home_root); + + let report = run_remote_group(&roots); + assert_eq!(report.status, MigrationRunStatus::CompletedWithWarnings); + assert_eq!( + owner::read_device_identity(&roots.target_home_root.join("device_identity.json")) + .unwrap(), + Some(target_device) + ); + let bot = owner::read_bot_persistence( + &roots + .target_home_root + .join("remote_connect_persistence.json"), + ) + .unwrap() + .unwrap(); + let connection = &bot.connections[0]; + assert!(matches!( + &connection.config, + BotConfigRecord::Telegram { bot_token } if bot_token == "current-secret" + )); + assert_eq!( + connection + .chat_state + .current_workspace + .as_ref() + .map(|workspace| workspace.path.as_str()), + Some("/srv/legacy") + ); + assert!(connection.chat_state.account_remote_context); + assert!(report + .requires_reauthentication + .contains(&"remote_connect_account".to_string())); + let report_json = serde_json::to_string(&report).unwrap(); + assert!(!report_json.contains("legacy-secret")); + assert!(!report_json.contains("current-secret")); + assert_eq!(source_hashes(&roots.legacy_home_root), before); + assert_stage_redacted(&roots, &report.run_id, &["legacy-secret", "current-secret"]); + } + + #[test] + fn canonical_backup_blocks_legacy_bot_fallback() { + let temp = test_tempdir("fallback"); + let roots = fixture_roots(temp.path()); + seed_probe_source(&roots); + fs::create_dir_all(&roots.legacy_home_root).unwrap(); + owner::write_bot_persistence( + &roots.legacy_home_root.join("bot_connections.json"), + &bot_fixture("fallback-secret", None, false), + ) + .unwrap(); + fs::write( + roots + .legacy_home_root + .join("remote_connect_persistence.json.bak"), + b"unresolved owner transaction", + ) + .unwrap(); + + let report = run_remote_group(&roots); + assert_eq!(report.status, MigrationRunStatus::CompletedWithWarnings); + assert!(!roots + .target_home_root + .join("remote_connect_persistence.json") + .exists()); + let remote = report + .domain_results + .iter() + .find(|result| result.domain == MigrationDomainId::RemoteConnectDevices) + .unwrap(); + assert!(remote + .warnings + .iter() + .any(|warning| warning.code == "legacy_bot_transaction_unresolved")); + assert!(!serde_json::to_string(&report) + .unwrap() + .contains("fallback-secret")); + } + + #[test] + fn remote_connect_commit_resumes_after_the_owner_write_without_duplicates() { + let temp = test_tempdir("crash-recovery"); + let roots = fixture_roots(temp.path()); + seed_probe_source(&roots); + fs::create_dir_all(&roots.legacy_home_root).unwrap(); + owner::write_bot_persistence( + &roots + .legacy_home_root + .join("remote_connect_persistence.json"), + &bot_fixture("recovery-secret", Some("/srv/recovery"), true), + ) + .unwrap(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::RemoteConnectionsAndDevices]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let crash = CrashOnce { + point: CrashPoint::AfterCommit(MigrationDomainId::RemoteConnectDevices), + fired: AtomicBool::new(false), + }; + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &crash), + Err(LegacyMigrationError::InjectedCrash( + CrashPoint::AfterCommit(MigrationDomainId::RemoteConnectDevices) + )) + )); + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + assert!(matches!( + report.status, + MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings + )); + let bot = owner::read_bot_persistence( + &roots + .target_home_root + .join("remote_connect_persistence.json"), + ) + .unwrap() + .unwrap(); + assert_eq!(bot.connections.len(), 1); + } + + #[test] + fn account_sync_merge_keeps_target_hashes_and_whole_version_pairs() { + let source = AccountSyncStateRecord { + last_session_since: 9, + uploaded_hashes: std::collections::HashMap::from([ + ("shared".to_string(), "source-hash".to_string()), + ("source-only".to_string(), "source-only-hash".to_string()), + ]), + }; + let target = AccountSyncStateRecord { + last_session_since: 7, + uploaded_hashes: std::collections::HashMap::from([( + "shared".to_string(), + "target-hash".to_string(), + )]), + }; + let merged = merge_sync_state(source, target); + assert_eq!(merged.last_session_since, 9); + assert_eq!(merged.uploaded_hashes["shared"], "target-hash"); + assert_eq!(merged.uploaded_hashes["source-only"], "source-only-hash"); + + let selected = choose_settings_cursor( + SettingsCursorRecord { + version: 4, + hash: "source-pair".to_string(), + }, + Some(SettingsCursorRecord { + version: 6, + hash: "target-pair".to_string(), + }), + ); + assert_eq!(selected.version, 6); + assert_eq!(selected.hash, "target-pair"); + } + + struct CrashOnce { + point: CrashPoint, + fired: AtomicBool, + } + + impl CrashInjector for CrashOnce { + fn should_crash(&self, point: CrashPoint) -> bool { + point == self.point && !self.fired.swap(true, Ordering::AcqRel) + } + } + + fn bot_fixture( + token: &str, + workspace: Option<&str>, + account_remote_context: bool, + ) -> BotPersistenceRecord { + BotPersistenceRecord { + connections: vec![SavedBotConnectionRecord { + bot_type: "telegram".to_string(), + chat_id: "chat-1".to_string(), + config: BotConfigRecord::Telegram { + bot_token: token.to_string(), + }, + chat_state: BotChatStateRecord { + chat_id: "chat-1".to_string(), + paired: true, + current_workspace: workspace.map(|path| owner::BotWorkspaceRefRecord { + path: path.to_string(), + remote_connection_id: Some("ssh-user@example.invalid:22".to_string()), + remote_ssh_host: Some("example.invalid".to_string()), + }), + current_assistant: None, + current_assistant_name: None, + current_session_id: None, + display_mode: owner::BotDisplayModeRecord::Assistant, + account_remote_context, + }, + connected_at: 1, + }], + form_state: RemoteConnectFormStateRecord::default(), + verbose_mode: false, + } + } + + fn run_remote_group( + roots: &MigrationRoots, + ) -> openbitfun_product_domains::legacy_migration::MigrationRunReport { + let source = probe_legacy_source(roots, ProbeLimits::default()) + .unwrap() + .expect("legacy source"); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::RemoteConnectionsAndDevices]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap() + } + + fn seed_probe_source(roots: &MigrationRoots) { + let path = roots.legacy_user_root.join("config/app.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, r#"{"version":"0.2.19"}"#).unwrap(); + } + + fn fixture_roots(root: &Path) -> MigrationRoots { + MigrationRoots { + legacy_user_root: root.join("legacy/user"), + legacy_home_root: root.join("legacy/home"), + legacy_skills_root: root.join("legacy/skills"), + legacy_ssh_root: root.join("legacy/ssh"), + target_user_root: root.join("target/user"), + target_home_root: root.join("target/home"), + target_skills_root: root.join("target/skills"), + target_ssh_root: root.join("target/ssh"), + } + } + + fn source_hashes(root: &Path) -> BTreeMap { + let mut values = BTreeMap::new(); + for relative in [ + "device_identity.json", + "remote_connect_persistence.json", + "account_session.enc", + "account_session.key", + ] { + let path = root.join(relative); + if path.exists() { + values.insert(relative.to_string(), file_digest(&path).unwrap()); + } + } + values + } + + fn assert_stage_redacted(roots: &MigrationRoots, run_id: &str, secrets: &[&str]) { + let stage = roots + .migration_root() + .join("runs") + .join(run_id) + .join("stage/remote-connect"); + for entry in fs::read_dir(stage).unwrap() { + let path = entry.unwrap().path(); + if path.is_file() { + let contents = fs::read_to_string(path).unwrap(); + for secret in secrets { + assert!(!contents.contains(secret)); + } + } + } + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix(&format!("remote-connect-migration-{label}-")) + .tempdir_in(root) + .unwrap() + } +} diff --git a/src/crates/assembly/core/src/legacy_migration/remote_ssh.rs b/src/crates/assembly/core/src/legacy_migration/remote_ssh.rs new file mode 100644 index 0000000000..6426cace95 --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/remote_ssh.rs @@ -0,0 +1,1185 @@ +use super::common::{ + backup_domain_dir, backup_file_once, io_error, read_bounded_json, read_optional_bounded_json, + relative_display, restore_unverified_file, stage_domain_dir, validate_regular_file, + MAX_JSON_BYTES, +}; +use openbitfun_legacy_migration::{ + atomic_write_json, DomainContext, DomainScan, LegacyDomainAdapter, LegacyMigrationError, + LegacyMigrationResult, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + ConflictResolution, FindingSeverity, MigrationConflict, MigrationDiagnostic, MigrationDomainId, + MigrationDomainResult, MigrationDomainState, ScanFinding, +}; +use openbitfun_services_integrations::remote_persistence as owner; +use owner::{ + KnownHostRecord, RemoteWorkspaceRecord, SavedAuthTypeRecord, SavedConnectionRecord, + SshVaultRecord, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const DOMAIN_DIR: &str = "remote-ssh"; +const SOURCE_SCHEMA: &str = "bitfun.remote-ssh.v0.2.19"; +const TARGET_SCHEMA: &str = "openbitfun.remote-ssh.current"; +const FILES: [&str; 5] = [ + "ssh_connections.json", + "remote_workspace.json", + "known_hosts", + ".ssh_password_vault.key", + "ssh_password_vault.json", +]; +const MAX_VAULT_BYTES: u64 = 16 * 1024 * 1024; + +pub(crate) struct RemoteSshAdapter; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum VaultHealth { + #[default] + Absent, + Valid, + Invalid, +} + +struct SshState { + files: BTreeMap, + connections: Vec, + workspaces: Vec, + known_hosts: Vec, + vault_health: VaultHealth, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ProfileOrigin { + Source, + Target, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct VaultSource { + origin: ProfileOrigin, + original_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemoteSshManifest { + source_files: BTreeMap, + target_before: BTreeMap>, + staged_files: BTreeMap, + vault_sources: BTreeMap, + source_vault_health: VaultHealth, + target_vault_health: VaultHealth, + imported: u64, + skipped: u64, + conflicts: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct RemoteSshReceipt { + manifest_digest: String, + completed: bool, + post_files: BTreeMap>, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct RemoteSshOutcome { + imported: u64, + skipped: u64, + conflicts: u64, + warnings: Vec, + requires_reauthentication: Vec, + requires_relocation: Vec, +} + +struct MergePlan { + connections: Vec, + workspaces: Vec, + known_hosts: Vec, + vault_sources: BTreeMap, + conflicts: Vec, + imported: u64, + skipped: u64, +} + +impl LegacyDomainAdapter for RemoteSshAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::RemoteSsh + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let source = read_state(&roots.legacy_ssh_root, true)?; + let target = read_state(&roots.target_ssh_root, false)?; + let merged = merge_states(&source, &target); + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: if source.files.is_empty() { + "legacy_remote_ssh_absent".to_string() + } else { + "legacy_remote_ssh_supported".to_string() + }, + severity: if merged.conflicts.is_empty() + && source.vault_health != VaultHealth::Invalid + { + FindingSeverity::Info + } else { + FindingSeverity::Warning + }, + entity_count: (source.connections.len() + + source.workspaces.len() + + source.known_hosts.len()) as u64, + logical_bytes: total_bytes(&roots.legacy_ssh_root, source.files.keys())?, + source_schema: Some(SOURCE_SCHEMA.to_string()), + migratable: true, + detail: "Legacy SSH profiles, POSIX workspace registrations, host trust, and credential references were inspected as one atomic subdomain.".to_string(), + }, + conflicts: merged.conflicts, + target_schema: Some(TARGET_SCHEMA.to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + reset_stage(context)?; + let source = read_state(&context.roots.legacy_ssh_root, true)?; + let target = read_state(&context.roots.target_ssh_root, false)?; + let merged = merge_states(&source, &target); + let domain_root = stage_domain_dir(context, DOMAIN_DIR); + owner::write_saved_connections( + &domain_root.join("ssh_connections.json"), + &merged.connections, + ) + .map_err(owner_error)?; + owner::write_remote_workspaces( + &domain_root.join("remote_workspace.json"), + &merged.workspaces, + ) + .map_err(owner_error)?; + owner::write_known_hosts(&domain_root.join("known_hosts"), &merged.known_hosts) + .map_err(owner_error)?; + let staged_files = [ + "ssh_connections.json", + "remote_workspace.json", + "known_hosts", + ] + .into_iter() + .map(|relative| { + let path = domain_root.join(relative); + Ok((relative.to_string(), file_digest(&path)?)) + }) + .collect::>>()?; + let target_before = FILES + .into_iter() + .map(|relative| (relative.to_string(), target.files.get(relative).cloned())) + .collect(); + let manifest = RemoteSshManifest { + source_files: source.files, + target_before, + staged_files, + vault_sources: merged.vault_sources, + source_vault_health: source.vault_health, + target_vault_health: target.vault_health, + imported: merged.imported, + skipped: merged.skipped, + conflicts: merged.conflicts.len() as u64, + }; + atomic_write_json(&manifest_path(context), &manifest)?; + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported: manifest.imported, + skipped: manifest.skipped, + conflicts: manifest.conflicts, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context)?; + let source = read_state(&context.roots.legacy_ssh_root, true)?; + if source.files != manifest.source_files + || source.vault_health != manifest.source_vault_health + { + return Err(LegacyMigrationError::InvalidRequest( + "legacy SSH inputs changed after staging".to_string(), + )); + } + if let Some(receipt) = read_optional_bounded_json::( + &context.layout.stage_root(), + &receipt_path(context), + )? { + if receipt.manifest_digest != json_digest(&manifest)? { + return Err(LegacyMigrationError::InvalidRequest( + "SSH commit receipt does not match its staged manifest".to_string(), + )); + } + return validate_staged_files(context, &manifest); + } + if current_files(&context.roots.target_ssh_root)? != manifest.target_before { + return Err(LegacyMigrationError::InvalidRequest( + "current SSH data changed after staging".to_string(), + )); + } + validate_staged_files(context, &manifest) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_manifest(context)?; + validate_staged_files(context, &manifest)?; + let manifest_digest = json_digest(&manifest)?; + let receipt = read_optional_bounded_json::( + &context.layout.stage_root(), + &receipt_path(context), + )?; + if let Some(receipt) = &receipt { + if receipt.manifest_digest != manifest_digest { + return Err(LegacyMigrationError::InvalidRequest( + "SSH commit receipt does not match its manifest".to_string(), + )); + } + if receipt.completed + && current_files(&context.roots.target_ssh_root)? == receipt.post_files + { + return Ok(()); + } + } else { + if current_files(&context.roots.target_ssh_root)? != manifest.target_before { + return Err(LegacyMigrationError::InvalidRequest( + "current SSH data changed before commit".to_string(), + )); + } + backup_targets(context, &manifest.target_before)?; + atomic_write_json( + &receipt_path(context), + &RemoteSshReceipt { + manifest_digest: manifest_digest.clone(), + completed: false, + post_files: BTreeMap::new(), + }, + )?; + } + let source = read_state(&context.roots.legacy_ssh_root, true)?; + if source.files != manifest.source_files { + return Err(LegacyMigrationError::InvalidRequest( + "legacy SSH inputs changed during commit".to_string(), + )); + } + let original = read_state(&backup_domain_dir(context, DOMAIN_DIR), false)?; + let staged = read_staged(context)?; + let mut outcome = RemoteSshOutcome { + imported: manifest.imported, + skipped: manifest.skipped, + conflicts: manifest.conflicts, + ..RemoteSshOutcome::default() + }; + if manifest.conflicts > 0 { + outcome.warnings.push(warning( + "ssh_conflicts_require_review", + "One or more SSH target records won or require explicit host-key review.", + )); + } + if manifest.source_files.is_empty() { + let post_files = current_files(&context.roots.target_ssh_root)?; + atomic_write_json(&outcome_path(context), &outcome)?; + return atomic_write_json( + &receipt_path(context), + &RemoteSshReceipt { + manifest_digest, + completed: true, + post_files, + }, + ); + } + fs::create_dir_all(&context.roots.target_ssh_root) + .map_err(|error| io_error(&context.roots.target_ssh_root, error))?; + owner::write_saved_connections( + &context.roots.target_ssh_root.join("ssh_connections.json"), + &staged.connections, + ) + .map_err(owner_error)?; + owner::write_remote_workspaces( + &context.roots.target_ssh_root.join("remote_workspace.json"), + &staged.workspaces, + ) + .map_err(owner_error)?; + owner::write_known_hosts( + &context.roots.target_ssh_root.join("known_hosts"), + &staged.known_hosts, + ) + .map_err(owner_error)?; + migrate_vault( + context, + &source, + &original, + &staged.connections, + &manifest, + &mut outcome, + )?; + record_unavailable_workspace_references(&staged, &mut outcome); + let post_files = current_files(&context.roots.target_ssh_root)?; + atomic_write_json(&outcome_path(context), &outcome)?; + atomic_write_json( + &receipt_path(context), + &RemoteSshReceipt { + manifest_digest, + completed: true, + post_files, + }, + ) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let receipt: RemoteSshReceipt = + read_bounded_json(&context.layout.stage_root(), &receipt_path(context))?; + if !receipt.completed + || current_files(&context.roots.target_ssh_root)? != receipt.post_files + { + return Err(LegacyMigrationError::InvalidRequest( + "current SSH owner did not retain the committed data".to_string(), + )); + } + let current = read_state(&context.roots.target_ssh_root, false)?; + if current.vault_health == VaultHealth::Invalid { + // A pre-existing invalid target vault is retained rather than + // overwritten. Its affected profiles are reported for repair. + let original = read_manifest(context)?; + if original.target_vault_health != VaultHealth::Invalid { + return Err(LegacyMigrationError::InvalidRequest( + "committed SSH password vault is not readable".to_string(), + )); + } + } + Ok(()) + } + + fn finalize_result( + &self, + context: &DomainContext<'_>, + staged: &MigrationDomainResult, + ) -> LegacyMigrationResult { + let outcome: RemoteSshOutcome = + read_bounded_json(&context.layout.stage_root(), &outcome_path(context))?; + let mut result = staged.clone(); + result.imported = outcome.imported; + result.skipped = outcome.skipped; + result.conflicts = outcome.conflicts; + result.warnings = outcome.warnings; + result.requires_reauthentication = outcome.requires_reauthentication; + result.requires_relocation = outcome.requires_relocation; + Ok(result) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let Some(manifest) = read_optional_bounded_json::( + &context.layout.stage_root(), + &manifest_path(context), + )? + else { + return Ok(()); + }; + let backup_root = backup_domain_dir(context, DOMAIN_DIR); + for (relative, digest) in &manifest.target_before { + restore_unverified_file( + &context.roots.target_ssh_root.join(relative), + &backup_root.join(relative), + digest.is_some(), + )?; + } + Ok(()) + } +} + +fn read_state(root: &Path, legacy: bool) -> LegacyMigrationResult { + let mut files = BTreeMap::new(); + for relative in FILES { + let path = root.join(relative); + if existing_regular( + root, + &path, + if relative.contains("vault") { + MAX_VAULT_BYTES + } else { + MAX_JSON_BYTES + }, + )? { + files.insert(relative.to_string(), file_digest(&path)?); + } + } + let connections = if files.contains_key("ssh_connections.json") { + owner::read_saved_connections(&root.join("ssh_connections.json")) + .map_err(owner_error)? + .unwrap_or_default() + } else { + Vec::new() + }; + let workspaces = if files.contains_key("remote_workspace.json") { + if legacy { + owner::read_legacy_remote_workspaces(&root.join("remote_workspace.json")) + } else { + owner::read_current_remote_workspaces(&root.join("remote_workspace.json")) + } + .map_err(owner_error)? + .unwrap_or_default() + } else { + Vec::new() + }; + let known_hosts = if files.contains_key("known_hosts") { + owner::read_known_hosts(&root.join("known_hosts")) + .map_err(owner_error)? + .unwrap_or_default() + } else { + Vec::new() + }; + let vault_health = if files.contains_key(".ssh_password_vault.key") + || files.contains_key("ssh_password_vault.json") + { + match owner::read_ssh_vault(root) { + Ok(Some(_)) => VaultHealth::Valid, + Ok(None) => VaultHealth::Absent, + Err(_) => VaultHealth::Invalid, + } + } else { + VaultHealth::Absent + }; + Ok(SshState { + files, + connections, + workspaces, + known_hosts, + vault_health, + }) +} + +fn merge_states(source: &SshState, target: &SshState) -> MergePlan { + let mut conflicts = Vec::new(); + let mut imported = 0u64; + let mut skipped = 0u64; + let mut connections = Vec::new(); + let mut vault_sources = BTreeMap::new(); + let mut target_ids = BTreeSet::new(); + let mut source_id_map = BTreeMap::new(); + let mut target_id_map = BTreeMap::new(); + + for connection in &target.connections { + let mut connection = connection.clone(); + let original_id = connection.id.clone(); + connection.id = canonical_connection_id(&connection.id); + target_id_map.insert(original_id.clone(), connection.id.clone()); + if target_ids.insert(connection.id.clone()) { + vault_sources.insert( + connection.id.clone(), + VaultSource { + origin: ProfileOrigin::Target, + original_id, + }, + ); + connections.push(connection); + } + } + for connection in &source.connections { + let mut connection = connection.clone(); + let original_id = connection.id.clone(); + connection.id = canonical_connection_id(&connection.id); + source_id_map.insert(original_id.clone(), connection.id.clone()); + if target_ids.insert(connection.id.clone()) { + imported = imported.saturating_add(1); + vault_sources.insert( + connection.id.clone(), + VaultSource { + origin: ProfileOrigin::Source, + original_id, + }, + ); + connections.push(connection); + } else { + skipped = skipped.saturating_add(1); + conflicts.push(MigrationConflict { + domain: MigrationDomainId::RemoteSsh, + code: "ssh_connection_target_wins".to_string(), + source_summary: "legacy SSH connection".to_string(), + target_summary: "current SSH connection with the same stable id".to_string(), + resolution: ConflictResolution::TargetWins, + }); + } + } + + let mut workspace_keys = BTreeSet::new(); + let mut workspaces = Vec::new(); + for (origin, values, mapping) in [ + (ProfileOrigin::Target, &target.workspaces, &target_id_map), + (ProfileOrigin::Source, &source.workspaces, &source_id_map), + ] { + for workspace in values { + let mut workspace = workspace.clone(); + workspace.connection_id = mapping + .get(&workspace.connection_id) + .cloned() + .unwrap_or_else(|| canonical_connection_id(&workspace.connection_id)); + let key = ( + workspace.connection_id.clone(), + workspace.remote_path.clone(), + ); + if workspace_keys.insert(key) { + if origin == ProfileOrigin::Source { + imported = imported.saturating_add(1); + } + workspaces.push(workspace); + } else if origin == ProfileOrigin::Source { + skipped = skipped.saturating_add(1); + } + } + } + + let mut known_hosts_by_key = BTreeMap::<(String, u16), KnownHostRecord>::new(); + for host in &target.known_hosts { + known_hosts_by_key.insert((host.host.clone(), host.port), host.clone()); + } + for host in &source.known_hosts { + let key = (host.host.clone(), host.port); + match known_hosts_by_key.get(&key) { + None => { + known_hosts_by_key.insert(key, host.clone()); + imported = imported.saturating_add(1); + } + Some(existing) + if existing.fingerprint == host.fingerprint + && existing.public_key == host.public_key => + { + skipped = skipped.saturating_add(1); + } + Some(existing) => { + skipped = skipped.saturating_add(1); + conflicts.push(MigrationConflict { + domain: MigrationDomainId::RemoteSsh, + code: "ssh_known_host_conflict".to_string(), + source_summary: format!("legacy fingerprint {}", host.fingerprint), + target_summary: format!("current fingerprint {}", existing.fingerprint), + resolution: ConflictResolution::RequiresUserAction, + }); + } + } + } + MergePlan { + connections, + workspaces, + known_hosts: known_hosts_by_key.into_values().collect(), + vault_sources, + conflicts, + imported, + skipped, + } +} + +fn migrate_vault( + context: &DomainContext<'_>, + source: &SshState, + original: &SshState, + connections: &[SavedConnectionRecord], + manifest: &RemoteSshManifest, + outcome: &mut RemoteSshOutcome, +) -> LegacyMigrationResult<()> { + let source_vault = if source.vault_health == VaultHealth::Valid { + owner::read_ssh_vault(&context.roots.legacy_ssh_root).map_err(owner_error)? + } else { + None + }; + let original_root = backup_domain_dir(context, DOMAIN_DIR); + let target_vault = if original.vault_health == VaultHealth::Valid { + owner::read_ssh_vault(&original_root).map_err(owner_error)? + } else { + None + }; + if original.vault_health == VaultHealth::Invalid { + for connection in connections + .iter() + .filter(|connection| needs_password(connection)) + { + require_ssh_reauthentication(outcome, &connection.id, "target_ssh_vault_unavailable"); + } + outcome.warnings.push(warning( + "target_ssh_vault_unavailable", + "The existing SSH password vault was retained unchanged because the current owner could not read it.", + )); + return Ok(()); + } + + let mut output = target_vault.unwrap_or_else(owner::new_ssh_vault); + let mut changed = false; + for connection in connections + .iter() + .filter(|connection| needs_password(connection)) + { + let Some(source_ref) = manifest.vault_sources.get(&connection.id) else { + require_ssh_reauthentication(outcome, &connection.id, "ssh_password_reference_missing"); + continue; + }; + let already_present = output + .decrypt(&connection.id) + .map_err(owner_error)? + .is_some(); + if already_present { + continue; + } + let credential = match source_ref.origin { + ProfileOrigin::Target => { + target_vault_entry(&output, &source_ref.original_id, &connection.id) + .map_err(owner_error)? + } + ProfileOrigin::Source => source_vault + .as_ref() + .map(|vault| vault.decrypt(&source_ref.original_id)) + .transpose() + .map_err(owner_error)? + .flatten(), + }; + if let Some(credential) = credential { + output + .store(connection.id.clone(), &credential) + .map_err(owner_error)?; + if source_ref.origin == ProfileOrigin::Target && source_ref.original_id != connection.id + { + output.remove(&source_ref.original_id); + } + changed = true; + } else { + require_ssh_reauthentication(outcome, &connection.id, "ssh_password_unavailable"); + } + } + if source.vault_health == VaultHealth::Invalid { + outcome.warnings.push(warning( + "legacy_ssh_vault_unavailable", + "SSH profiles and workspaces were retained, but one or more legacy passwords could not be transferred.", + )); + } + if changed || original.vault_health == VaultHealth::Valid { + owner::write_ssh_vault(&context.roots.target_ssh_root, &output).map_err(owner_error)?; + } + Ok(()) +} + +fn target_vault_entry( + vault: &SshVaultRecord, + original_id: &str, + canonical_id: &str, +) -> anyhow::Result> { + if original_id == canonical_id { + return vault.decrypt(canonical_id); + } + vault.decrypt(original_id) +} + +fn needs_password(connection: &SavedConnectionRecord) -> bool { + matches!(connection.auth_type, SavedAuthTypeRecord::Password) + && connection + .container + .as_ref() + .is_none_or(|container| !container.local) +} + +fn require_ssh_reauthentication(outcome: &mut RemoteSshOutcome, id: &str, code: &str) { + outcome.requires_reauthentication.push(id.to_string()); + outcome.warnings.push(MigrationDiagnostic { + code: code.to_string(), + severity: FindingSeverity::Warning, + domain: Some(MigrationDomainId::RemoteSsh), + message: "An SSH profile was retained but requires credential re-entry.".to_string(), + action: Some("Enter the credential again before reconnecting".to_string()), + ..MigrationDiagnostic::default() + }); +} + +fn record_unavailable_workspace_references(state: &SshState, outcome: &mut RemoteSshOutcome) { + let connection_ids = state + .connections + .iter() + .map(|connection| connection.id.as_str()) + .collect::>(); + for workspace in &state.workspaces { + if !connection_ids.contains(workspace.connection_id.as_str()) { + outcome + .requires_relocation + .push(workspace.connection_id.clone()); + outcome.warnings.push(warning( + "ssh_workspace_profile_missing", + "A remote workspace was retained without silently falling back to a local path, but its SSH profile needs repair.", + )); + } + } +} + +fn canonical_connection_id(id: &str) -> String { + if let Some(rest) = id.strip_prefix("ssh-") { + if let (Some(at), Some(colon)) = (rest.find('@'), rest.rfind(':')) { + if colon > at && rest[colon + 1..].parse::().is_ok() { + return format!("ssh-{}", &rest[..colon]); + } + } + } + id.to_string() +} + +fn read_staged(context: &DomainContext<'_>) -> LegacyMigrationResult { + read_state(&stage_domain_dir(context, DOMAIN_DIR), false) +} + +fn validate_staged_files( + context: &DomainContext<'_>, + manifest: &RemoteSshManifest, +) -> LegacyMigrationResult<()> { + let staged = read_staged(context)?; + let actual = [ + "ssh_connections.json", + "remote_workspace.json", + "known_hosts", + ] + .into_iter() + .map(|relative| { + let path = stage_domain_dir(context, DOMAIN_DIR).join(relative); + Ok((relative.to_string(), file_digest(&path)?)) + }) + .collect::>>()?; + if actual != manifest.staged_files + || staged.files.len() != 3 + || staged.vault_health != VaultHealth::Absent + { + return Err(LegacyMigrationError::InvalidRequest( + "staged SSH owner data differs from its manifest".to_string(), + )); + } + Ok(()) +} + +fn current_files(root: &Path) -> LegacyMigrationResult>> { + FILES + .into_iter() + .map(|relative| { + let path = root.join(relative); + let digest = match fs::symlink_metadata(&path) { + Ok(_) => { + validate_regular_file(root, &path)?; + Some(file_digest(&path)?) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(io_error(&path, error)), + }; + Ok((relative.to_string(), digest)) + }) + .collect() +} + +fn backup_targets( + context: &DomainContext<'_>, + target_before: &BTreeMap>, +) -> LegacyMigrationResult<()> { + let backup_root = backup_domain_dir(context, DOMAIN_DIR); + for (relative, digest) in target_before { + if digest.is_some() { + let target = context.roots.target_ssh_root.join(relative); + let backup = backup_root.join(relative); + if matches!( + relative.as_str(), + ".ssh_password_vault.key" | "ssh_password_vault.json" + ) { + if !backup.exists() { + let bytes = fs::read(&target).map_err(|error| io_error(&target, error))?; + owner::write_private_bytes(&backup, &bytes).map_err(owner_error)?; + } + } else { + backup_file_once(&target, &backup)?; + } + } + } + Ok(()) +} + +fn existing_regular(root: &Path, path: &Path, max_bytes: u64) -> LegacyMigrationResult { + match fs::symlink_metadata(path) { + Ok(_) => { + validate_regular_file(root, path)?; + let bytes = fs::metadata(path) + .map_err(|error| io_error(path, error))? + .len(); + if bytes > max_bytes { + return Err(LegacyMigrationError::ResourceLimit(format!( + "SSH persistence file exceeds {max_bytes} bytes: {}", + relative_display(root, path) + ))); + } + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(io_error(path, error)), + } +} + +fn file_digest(path: &Path) -> LegacyMigrationResult { + let bytes = fs::read(path).map_err(|error| io_error(path, error))?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} + +fn json_digest(value: &impl Serialize) -> LegacyMigrationResult { + let bytes = serde_json::to_vec(value).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("serialize SSH manifest: {error}")) + })?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} + +fn total_bytes<'a>( + root: &Path, + paths: impl IntoIterator, +) -> LegacyMigrationResult { + let mut total = 0u64; + for relative in paths { + let path = root.join(relative); + total = total.saturating_add( + fs::metadata(&path) + .map_err(|error| io_error(&path, error))? + .len(), + ); + } + Ok(total) +} + +fn warning(code: &str, message: &str) -> MigrationDiagnostic { + MigrationDiagnostic { + code: code.to_string(), + severity: FindingSeverity::Warning, + domain: Some(MigrationDomainId::RemoteSsh), + message: message.to_string(), + action: Some("Review the SSH profile before reconnecting".to_string()), + ..MigrationDiagnostic::default() + } +} + +fn owner_error(error: anyhow::Error) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!( + "Remote SSH persistence owner rejected the data: {error:#}" + )) +} + +fn reset_stage(context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let path = stage_domain_dir(context, DOMAIN_DIR); + match fs::remove_dir_all(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error(&path, error)), + } + fs::create_dir_all(&path).map_err(|error| io_error(&path, error)) +} + +fn manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, DOMAIN_DIR).join("manifest.json") +} + +fn receipt_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, DOMAIN_DIR).join("commit-receipt.json") +} + +fn outcome_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, DOMAIN_DIR).join("outcome.json") +} + +fn read_manifest(context: &DomainContext<'_>) -> LegacyMigrationResult { + read_bounded_json(&context.layout.stage_root(), &manifest_path(context)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::legacy_migration::adapters_for_groups; + use openbitfun_legacy_migration::{ + probe_legacy_source, CancellationToken, MigrationEngine, NoCrashInjection, ProbeLimits, + }; + use openbitfun_product_domains::legacy_migration::{ + MigrationGroupId, MigrationRunStatus, MigrationSelection, + }; + + #[test] + fn ssh_merge_rewrites_reference_closure_and_keeps_target_host_key() { + let temp = test_tempdir("merge"); + let roots = fixture_roots(temp.path()); + seed_probe_source(&roots); + fs::create_dir_all(&roots.legacy_ssh_root).unwrap(); + fs::create_dir_all(&roots.target_ssh_root).unwrap(); + let source_connection = connection("ssh-user@example.invalid:22"); + owner::write_saved_connections( + &roots.legacy_ssh_root.join("ssh_connections.json"), + std::slice::from_ref(&source_connection), + ) + .unwrap(); + fs::write( + roots.legacy_ssh_root.join("remote_workspace.json"), + serde_json::to_vec_pretty(&workspace("ssh-user@example.invalid:22")).unwrap(), + ) + .unwrap(); + owner::write_known_hosts( + &roots.legacy_ssh_root.join("known_hosts"), + &[known_host("SHA256:legacy", "legacy-public-key")], + ) + .unwrap(); + let mut source_vault = owner::new_ssh_vault(); + source_vault + .store("ssh-user@example.invalid:22".to_string(), "legacy-password") + .unwrap(); + owner::write_ssh_vault(&roots.legacy_ssh_root, &source_vault).unwrap(); + owner::write_known_hosts( + &roots.target_ssh_root.join("known_hosts"), + &[known_host("SHA256:current", "current-public-key")], + ) + .unwrap(); + let before = source_hashes(&roots.legacy_ssh_root); + + let report = run_remote_group(&roots); + assert_eq!(report.status, MigrationRunStatus::CompletedWithWarnings); + let connections = + owner::read_saved_connections(&roots.target_ssh_root.join("ssh_connections.json")) + .unwrap() + .unwrap(); + assert_eq!(connections.len(), 1); + assert_eq!(connections[0].id, "ssh-user@example.invalid"); + let workspaces = owner::read_current_remote_workspaces( + &roots.target_ssh_root.join("remote_workspace.json"), + ) + .unwrap() + .unwrap(); + assert_eq!(workspaces[0].connection_id, "ssh-user@example.invalid"); + assert_eq!(workspaces[0].remote_path, "/srv/project"); + let known_hosts = owner::read_known_hosts(&roots.target_ssh_root.join("known_hosts")) + .unwrap() + .unwrap(); + assert_eq!(known_hosts[0].fingerprint, "SHA256:current"); + let vault = owner::read_ssh_vault(&roots.target_ssh_root) + .unwrap() + .expect("migrated vault"); + assert_eq!( + vault + .decrypt("ssh-user@example.invalid") + .unwrap() + .as_deref(), + Some("legacy-password") + ); + assert!(vault + .decrypt("ssh-user@example.invalid:22") + .unwrap() + .is_none()); + assert_eq!(source_hashes(&roots.legacy_ssh_root), before); + let report_json = serde_json::to_string(&report).unwrap(); + assert!(!report_json.contains("legacy-password")); + assert_stage_redacted(&roots, &report.run_id, "legacy-password"); + let ssh = report + .domain_results + .iter() + .find(|result| result.domain == MigrationDomainId::RemoteSsh) + .unwrap(); + assert!(ssh.conflicts >= 1); + } + + #[test] + fn corrupt_vault_retains_profiles_and_requests_reauthentication() { + let temp = test_tempdir("corrupt-vault"); + let roots = fixture_roots(temp.path()); + seed_probe_source(&roots); + fs::create_dir_all(&roots.legacy_ssh_root).unwrap(); + owner::write_saved_connections( + &roots.legacy_ssh_root.join("ssh_connections.json"), + &[connection("ssh-user@example.invalid:22")], + ) + .unwrap(); + fs::write( + roots.legacy_ssh_root.join(".ssh_password_vault.key"), + [0u8; 7], + ) + .unwrap(); + fs::write( + roots.legacy_ssh_root.join("ssh_password_vault.json"), + r#"{"entries":{"ssh-user@example.invalid:22":"invalid"}}"#, + ) + .unwrap(); + + let report = run_remote_group(&roots); + assert_eq!(report.status, MigrationRunStatus::CompletedWithWarnings); + let connections = + owner::read_saved_connections(&roots.target_ssh_root.join("ssh_connections.json")) + .unwrap() + .unwrap(); + assert_eq!(connections[0].id, "ssh-user@example.invalid"); + assert!(report + .requires_reauthentication + .contains(&"ssh-user@example.invalid".to_string())); + assert!(!roots + .target_ssh_root + .join("ssh_password_vault.json") + .exists()); + } + + #[test] + fn canonical_remote_fixture_is_read_by_current_owners_after_migration() { + let temp = test_tempdir("canonical-fixture"); + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../services/legacy-migration/tests/fixtures/v0.2.19"); + let roots = MigrationRoots { + legacy_user_root: fixture.join("user-root"), + legacy_home_root: fixture.join("home"), + legacy_skills_root: fixture.join("user-root/skills"), + legacy_ssh_root: fixture.join("ssh"), + target_user_root: temp.path().join("target/user"), + target_home_root: temp.path().join("target/home"), + target_skills_root: temp.path().join("target/skills"), + target_ssh_root: temp.path().join("target/ssh"), + }; + let report = run_remote_group(&roots); + assert!(matches!( + report.status, + MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings + )); + let identity = + owner::read_device_identity(&roots.target_home_root.join("device_identity.json")) + .unwrap() + .unwrap(); + assert_eq!(identity.device_id, "0123456789abcdef0123456789abcdef"); + let bot = owner::read_bot_persistence( + &roots + .target_home_root + .join("remote_connect_persistence.json"), + ) + .unwrap() + .unwrap(); + assert!(bot.connections[0].chat_state.account_remote_context); + let connections = + owner::read_saved_connections(&roots.target_ssh_root.join("ssh_connections.json")) + .unwrap() + .unwrap(); + assert_eq!(connections[0].id, "ssh-fixture@example.invalid"); + let workspaces = owner::read_current_remote_workspaces( + &roots.target_ssh_root.join("remote_workspace.json"), + ) + .unwrap() + .unwrap(); + assert_eq!(workspaces[0].connection_id, connections[0].id); + assert_eq!(workspaces[0].remote_path, "/srv/fixture-workspace"); + assert_eq!( + owner::read_known_hosts(&roots.target_ssh_root.join("known_hosts")) + .unwrap() + .unwrap() + .len(), + 1 + ); + } + + fn connection(id: &str) -> SavedConnectionRecord { + SavedConnectionRecord { + id: id.to_string(), + name: "fixture connection".to_string(), + host: "example.invalid".to_string(), + port: 22, + username: "user".to_string(), + auth_type: SavedAuthTypeRecord::Password, + default_workspace: Some("/srv/project".to_string()), + last_connected: Some(1), + proxy_jump: Some("jump.example.invalid".to_string()), + container: None, + options: owner::SshConnectionOptionsRecord::default(), + } + } + + fn workspace(connection_id: &str) -> RemoteWorkspaceRecord { + RemoteWorkspaceRecord { + connection_id: connection_id.to_string(), + remote_path: "/srv/project".to_string(), + connection_name: "fixture connection".to_string(), + ssh_host: "example.invalid".to_string(), + } + } + + fn known_host(fingerprint: &str, public_key: &str) -> KnownHostRecord { + KnownHostRecord { + host: "example.invalid".to_string(), + port: 22, + key_type: "ssh-ed25519".to_string(), + fingerprint: fingerprint.to_string(), + public_key: public_key.to_string(), + } + } + + fn run_remote_group( + roots: &MigrationRoots, + ) -> openbitfun_product_domains::legacy_migration::MigrationRunReport { + let source = probe_legacy_source(roots, ProbeLimits::default()) + .unwrap() + .expect("legacy source"); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::RemoteConnectionsAndDevices]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap() + } + + fn seed_probe_source(roots: &MigrationRoots) { + let path = roots.legacy_user_root.join("config/app.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, r#"{"version":"0.2.19"}"#).unwrap(); + } + + fn fixture_roots(root: &Path) -> MigrationRoots { + MigrationRoots { + legacy_user_root: root.join("legacy/user"), + legacy_home_root: root.join("legacy/home"), + legacy_skills_root: root.join("legacy/skills"), + legacy_ssh_root: root.join("legacy/ssh"), + target_user_root: root.join("target/user"), + target_home_root: root.join("target/home"), + target_skills_root: root.join("target/skills"), + target_ssh_root: root.join("target/ssh"), + } + } + + fn source_hashes(root: &Path) -> BTreeMap { + FILES + .into_iter() + .filter_map(|relative| { + let path = root.join(relative); + path.exists() + .then(|| (relative.to_string(), file_digest(&path).unwrap())) + }) + .collect() + } + + fn assert_stage_redacted(roots: &MigrationRoots, run_id: &str, secret: &str) { + let stage = roots + .migration_root() + .join("runs") + .join(run_id) + .join("stage/remote-ssh"); + for entry in fs::read_dir(stage).unwrap() { + let path = entry.unwrap().path(); + if path.is_file() { + assert!(!fs::read_to_string(path).unwrap().contains(secret)); + } + } + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix(&format!("remote-ssh-migration-{label}-")) + .tempdir_in(root) + .unwrap() + } +} diff --git a/src/crates/assembly/core/src/legacy_migration/settings.rs b/src/crates/assembly/core/src/legacy_migration/settings.rs new file mode 100644 index 0000000000..ef6070c835 --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/settings.rs @@ -0,0 +1,808 @@ +use super::common::{ + backup_domain_dir, backup_file_once, read_bounded_json, read_optional_bounded_json, + restore_unverified_file, stage_domain_dir, +}; +use crate::service::config::manager::validate_current_config_value; +use crate::service::config::types::{AIModelConfig, AgentProfileConfig, GlobalConfig}; +use openbitfun_legacy_migration::{ + atomic_write_json, DomainContext, DomainScan, LegacyDomainAdapter, LegacyMigrationError, + LegacyMigrationResult, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + ConflictResolution, FindingSeverity, MigrationConflict, MigrationDiagnostic, MigrationDomainId, + MigrationDomainResult, MigrationDomainState, ScanFinding, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; +use std::fs; +use std::path::PathBuf; + +const SOURCE_SCHEMA: &str = "bitfun.config.v1"; +const TARGET_SCHEMA: &str = "openbitfun.config.current"; + +pub(crate) struct SettingsAdapter; +pub(crate) struct CredentialsAdapter; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StagedSettings { + target_existed: bool, + imported: u64, + skipped: u64, + conflicts: u64, + config: GlobalConfig, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct CredentialManifest { + target_existed: bool, + model_ids: Vec, + voice_call: bool, + unsupported_secret_fields: Vec, +} + +#[derive(Default)] +struct MergeOutcome { + imported: u64, + skipped: u64, + conflicts: Vec, +} + +impl LegacyDomainAdapter for SettingsAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::Settings + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let source = read_source_config(roots)?; + let target = read_target_config(roots)?; + let (_, outcome) = merge_settings(&source, target)?; + let bytes = serde_json::to_vec(&source) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: "legacy_settings_supported".to_string(), + severity: FindingSeverity::Info, + entity_count: outcome.imported + outcome.skipped, + logical_bytes: bytes.len() as u64, + source_schema: Some(SOURCE_SCHEMA.to_string()), + migratable: true, + detail: "Supported legacy settings will be converted through the current configuration contract." + .to_string(), + }, + conflicts: outcome.conflicts, + target_schema: Some(TARGET_SCHEMA.to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let source = read_source_config(context.roots)?; + let target_path = target_config_path(context.roots); + let target_existed = target_path.exists(); + let target = read_target_config(context.roots)?; + let (config, outcome) = merge_settings(&source, target)?; + validate_current_config(&config, "staged legacy configuration")?; + let staged = StagedSettings { + target_existed, + imported: outcome.imported, + skipped: outcome.skipped, + conflicts: outcome.conflicts.len() as u64, + config, + }; + atomic_write_json(&settings_stage_path(context), &staged)?; + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported: staged.imported, + skipped: staged.skipped, + conflicts: staged.conflicts, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let staged: StagedSettings = + read_bounded_json(&context.layout.stage_root(), &settings_stage_path(context))?; + validate_current_config(&staged.config, "staged legacy configuration") + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let staged: StagedSettings = + read_bounded_json(&context.layout.stage_root(), &settings_stage_path(context))?; + let target = target_config_path(context.roots); + backup_file_once(&target, &settings_backup_path(context))?; + atomic_write_json(&target, &staged.config) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let expected: StagedSettings = + read_bounded_json(&context.layout.stage_root(), &settings_stage_path(context))?; + let actual = read_target_config(context.roots)?; + validate_current_config(&actual, "committed legacy configuration")?; + let expected_value = serde_json::to_value(expected.config) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + let actual_value = serde_json::to_value(actual) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + if actual_value != expected_value { + return Err(LegacyMigrationError::InvalidRequest( + "committed configuration does not match the staged owner model".to_string(), + )); + } + Ok(()) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let staged = read_optional_bounded_json::( + &context.layout.stage_root(), + &settings_stage_path(context), + )?; + if let Some(staged) = staged { + restore_unverified_file( + &target_config_path(context.roots), + &settings_backup_path(context), + staged.target_existed, + )?; + } + Ok(()) + } +} + +impl LegacyDomainAdapter for CredentialsAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::Credentials + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let source = read_source_config(roots)?; + let manifest = credential_manifest(&source, target_config_path(roots).exists()); + let count = manifest.model_ids.len() as u64 + u64::from(manifest.voice_call); + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: if count == 0 { + "legacy_credentials_absent" + } else { + "legacy_credentials_supported" + } + .to_string(), + severity: if manifest.unsupported_secret_fields.is_empty() { + FindingSeverity::Info + } else { + FindingSeverity::Warning + }, + entity_count: count, + logical_bytes: 0, + source_schema: Some(SOURCE_SCHEMA.to_string()), + migratable: true, + detail: "Credentials are read only during commit and are never written to migration staging or reports." + .to_string(), + }, + conflicts: Vec::new(), + target_schema: Some(TARGET_SCHEMA.to_string()), + dependencies: vec![MigrationDomainId::Settings], + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let source = read_source_config(context.roots)?; + let manifest = credential_manifest(&source, target_config_path(context.roots).exists()); + atomic_write_json(&credentials_stage_path(context), &manifest)?; + let warnings = manifest + .unsupported_secret_fields + .iter() + .map(|field| MigrationDiagnostic { + code: "credential_requires_reauthentication".to_string(), + severity: FindingSeverity::Warning, + domain: Some(self.domain()), + relative_path: None, + message: format!("Credential metadata at {field} is not safely portable."), + action: Some("Enter the credential again in OpenBitFun.".to_string()), + }) + .collect::>(); + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported: manifest.model_ids.len() as u64 + u64::from(manifest.voice_call), + skipped: manifest.unsupported_secret_fields.len() as u64, + warnings, + requires_reauthentication: manifest.unsupported_secret_fields.clone(), + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let bytes = fs::read(credentials_stage_path(context)).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("credential stage is unreadable: {error}")) + })?; + let manifest: CredentialManifest = serde_json::from_slice(&bytes).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("credential stage is invalid: {error}")) + })?; + let source = read_source_config(context.roots)?; + let mut secret_values = Vec::new(); + collect_secret_values(&source, &mut secret_values); + for secret in secret_values.into_iter().filter(|secret| secret.len() >= 4) { + if bytes + .windows(secret.len()) + .any(|window| window == secret.as_bytes()) + { + return Err(LegacyMigrationError::InvalidRequest( + "credential staging contains a prohibited secret value".to_string(), + )); + } + } + if manifest.model_ids.iter().any(|id| id.trim().is_empty()) { + return Err(LegacyMigrationError::InvalidRequest( + "credential staging contains an empty logical model id".to_string(), + )); + } + Ok(()) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let source = read_source_config(context.roots)?; + let target_path = target_config_path(context.roots); + let mut target = read_target_config(context.roots)?; + let manifest: CredentialManifest = read_bounded_json( + &context.layout.stage_root(), + &credentials_stage_path(context), + )?; + backup_file_once(&target_path, &credentials_backup_path(context))?; + + if let Some(models) = source.pointer("/ai/models").and_then(Value::as_array) { + for source_model in models { + let Some(id) = source_model.get("id").and_then(Value::as_str) else { + continue; + }; + if !manifest.model_ids.iter().any(|candidate| candidate == id) { + continue; + } + let Some(secret) = source_model.get("api_key").and_then(Value::as_str) else { + continue; + }; + if let Some(target_model) = target.ai.models.iter_mut().find(|model| model.id == id) + { + if target_model.api_key.is_empty() { + target_model.api_key = secret.to_string(); + } + } + } + } + if manifest.voice_call && target.app.voice_call.api_key.is_empty() { + if let Some(secret) = source + .pointer("/app/voice_call/api_key") + .and_then(Value::as_str) + { + target.app.voice_call.api_key = secret.to_string(); + } + } + validate_current_config(&target, "credential migration target")?; + atomic_write_json(&target_path, &target) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let source = read_source_config(context.roots)?; + let target = read_target_config(context.roots)?; + let manifest: CredentialManifest = read_bounded_json( + &context.layout.stage_root(), + &credentials_stage_path(context), + )?; + validate_current_config(&target, "committed credential configuration")?; + for id in &manifest.model_ids { + let source_secret = source + .pointer("/ai/models") + .and_then(Value::as_array) + .and_then(|models| { + models + .iter() + .find(|model| model.get("id").and_then(Value::as_str) == Some(id.as_str())) + }) + .and_then(|model| model.get("api_key")) + .and_then(Value::as_str); + let target_secret = target + .ai + .models + .iter() + .find(|model| model.id == *id) + .map(|model| model.api_key.as_str()); + if source_secret.is_some_and(|secret| !secret.is_empty()) + && target_secret.is_none_or(str::is_empty) + { + return Err(LegacyMigrationError::InvalidRequest(format!( + "credential for model {id} was not committed" + ))); + } + } + Ok(()) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_optional_bounded_json::( + &context.layout.stage_root(), + &credentials_stage_path(context), + )?; + if let Some(manifest) = manifest { + restore_unverified_file( + &target_config_path(context.roots), + &credentials_backup_path(context), + manifest.target_existed, + )?; + } + Ok(()) + } +} + +fn read_source_config(roots: &MigrationRoots) -> LegacyMigrationResult { + read_bounded_json(&roots.legacy_user_root, &source_config_path(roots)) +} + +fn read_target_config(roots: &MigrationRoots) -> LegacyMigrationResult { + let path = target_config_path(roots); + let value = read_optional_bounded_json::(&roots.target_user_root, &path)?; + match value { + Some(value) => { + validate_current_config_value(&value, "legacy migration target configuration") + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + serde_json::from_value(value).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "target configuration does not match the current owner model: {error}" + )) + }) + } + None => Ok(GlobalConfig::default()), + } +} + +fn merge_settings( + source: &Value, + mut target: GlobalConfig, +) -> LegacyMigrationResult<(GlobalConfig, MergeOutcome)> { + validate_source_version(source)?; + let defaults = GlobalConfig::default(); + let mut outcome = MergeOutcome::default(); + + merge_scalar( + source.pointer("/app/language").and_then(Value::as_str), + &mut target.app.language, + &defaults.app.language, + "app.language", + &mut outcome, + ); + merge_copy_scalar( + source.pointer("/app/auto_update").and_then(Value::as_bool), + &mut target.app.auto_update, + defaults.app.auto_update, + "app.auto_update", + &mut outcome, + ); + merge_copy_scalar( + source.pointer("/app/telemetry").and_then(Value::as_bool), + &mut target.app.telemetry, + defaults.app.telemetry, + "app.telemetry", + &mut outcome, + ); + if let Some(theme) = source + .pointer("/appearance/theme_id") + .and_then(Value::as_str) + { + let theme = canonical_product_id(theme); + merge_scalar( + Some(&theme), + &mut target.appearance.selection, + &defaults.appearance.selection, + "appearance.selection", + &mut outcome, + ); + } + + if let Some(profiles) = source + .pointer("/ai/agent_profiles") + .and_then(Value::as_object) + { + for (profile_id, profile) in profiles { + let enabled = profile + .get("enabled_skills") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(canonical_product_id) + .collect::>() + }) + .unwrap_or_default(); + if enabled.is_empty() { + continue; + } + let entry = target + .ai + .agent_profiles + .entry(profile_id.clone()) + .or_insert_with(|| AgentProfileConfig { + profile_id: profile_id.clone(), + ..AgentProfileConfig::default() + }); + if entry.enabled_user_skills.is_empty() { + entry.enabled_user_skills = enabled; + outcome.imported += 1; + } else { + record_target_wins( + &format!("ai.agent_profiles.{profile_id}.enabled_user_skills"), + &mut outcome, + ); + } + } + } + + if let Some(models) = source.pointer("/ai/models").and_then(Value::as_array) { + for raw in models { + let Ok(mut model) = serde_json::from_value::(raw.clone()) else { + outcome.skipped += 1; + continue; + }; + if model.id.trim().is_empty() { + outcome.skipped += 1; + continue; + } + model.api_key.clear(); + model.custom_headers = None; + model.custom_request_body = None; + if target + .ai + .models + .iter() + .any(|existing| existing.id == model.id) + { + record_target_wins(&format!("ai.models.{}", model.id), &mut outcome); + } else { + target.ai.models.push(model); + outcome.imported += 1; + } + } + } + + target.product_id = defaults.product_id; + target.schema_version = defaults.schema_version; + target.version = defaults.version; + target.last_modified = chrono::Utc::now(); + Ok((target, outcome)) +} + +fn credential_manifest(source: &Value, target_existed: bool) -> CredentialManifest { + let model_ids = source + .pointer("/ai/models") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|model| { + model + .get("api_key") + .and_then(Value::as_str) + .is_some_and(|secret| !secret.is_empty()) + }) + .filter_map(|model| model.get("id").and_then(Value::as_str)) + .filter(|id| !id.trim().is_empty()) + .map(str::to_string) + .collect::>() + .into_iter() + .collect(); + let voice_call = source + .pointer("/app/voice_call/api_key") + .and_then(Value::as_str) + .is_some_and(|secret| !secret.is_empty()); + let mut unsupported_secret_fields = Vec::new(); + collect_unsupported_secret_fields(source, "", &mut unsupported_secret_fields); + unsupported_secret_fields.retain(|path| { + path != "app.voice_call.api_key" + && !(path.starts_with("ai.models.") && path.ends_with(".api_key")) + }); + unsupported_secret_fields.sort(); + unsupported_secret_fields.dedup(); + CredentialManifest { + target_existed, + model_ids, + voice_call, + unsupported_secret_fields, + } +} + +fn collect_unsupported_secret_fields(value: &Value, path: &str, output: &mut Vec) { + match value { + Value::Object(fields) => { + for (name, value) in fields { + let next = if path.is_empty() { + name.to_string() + } else { + format!("{path}.{name}") + }; + let lower = name.to_ascii_lowercase(); + if matches!(lower.as_str(), "password" | "token" | "secret" | "api_key") + && value.as_str().is_some_and(|secret| !secret.is_empty()) + { + output.push(next.clone()); + } + collect_unsupported_secret_fields(value, &next, output); + } + } + Value::Array(items) => { + for (index, value) in items.iter().enumerate() { + collect_unsupported_secret_fields(value, &format!("{path}.{index}"), output); + } + } + _ => {} + } +} + +fn collect_secret_values(value: &Value, output: &mut Vec) { + match value { + Value::Object(fields) => { + for (name, value) in fields { + let lower = name.to_ascii_lowercase(); + if matches!(lower.as_str(), "password" | "token" | "secret" | "api_key") { + if let Some(secret) = value.as_str().filter(|secret| !secret.is_empty()) { + output.push(secret.to_string()); + } + } + collect_secret_values(value, output); + } + } + Value::Array(items) => { + for value in items { + collect_secret_values(value, output); + } + } + _ => {} + } +} + +fn validate_source_version(source: &Value) -> LegacyMigrationResult<()> { + let schema = source.get("schema_version").and_then(Value::as_u64); + let version = source.get("version").and_then(Value::as_str); + if schema != Some(1) || !version.is_some_and(|version| version.starts_with("0.")) { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "expected BitFun config schema 1 from a 0.x release, found schema={schema:?}, version={version:?}" + ))); + } + Ok(()) +} + +fn validate_current_config(config: &GlobalConfig, context: &str) -> LegacyMigrationResult<()> { + let value = serde_json::to_value(config) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + validate_current_config_value(&value, context) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + serde_json::from_value::(value) + .map(|_| ()) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string())) +} + +fn merge_scalar( + source: Option<&str>, + target: &mut String, + default: &str, + path: &str, + outcome: &mut MergeOutcome, +) { + let Some(source) = source else { return }; + if target == default { + *target = source.to_string(); + outcome.imported += 1; + } else { + record_target_wins(path, outcome); + } +} + +fn merge_copy_scalar( + source: Option, + target: &mut T, + default: T, + path: &str, + outcome: &mut MergeOutcome, +) { + let Some(source) = source else { return }; + if *target == default { + *target = source; + outcome.imported += 1; + } else { + record_target_wins(path, outcome); + } +} + +fn record_target_wins(path: &str, outcome: &mut MergeOutcome) { + outcome.skipped += 1; + outcome.conflicts.push(MigrationConflict { + domain: MigrationDomainId::Settings, + code: "target_setting_wins".to_string(), + source_summary: format!("legacy setting {path}"), + target_summary: format!("existing OpenBitFun setting {path}"), + resolution: ConflictResolution::TargetWins, + }); +} + +fn canonical_product_id(value: &str) -> String { + value + .replace("user::bitfun::", "user::openbitfun::") + .replace("bitfun-", "openbitfun-") +} + +fn source_config_path(roots: &MigrationRoots) -> PathBuf { + roots.legacy_user_root.join("config").join("app.json") +} + +fn target_config_path(roots: &MigrationRoots) -> PathBuf { + roots.target_user_root.join("config").join("app.json") +} + +fn settings_stage_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "settings").join("app.json") +} + +fn credentials_stage_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "credentials").join("manifest.json") +} + +fn settings_backup_path(context: &DomainContext<'_>) -> PathBuf { + backup_domain_dir(context, "settings").join("app.json") +} + +fn credentials_backup_path(context: &DomainContext<'_>) -> PathBuf { + backup_domain_dir(context, "credentials").join("app.json") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::legacy_migration::adapters_for_groups; + use openbitfun_legacy_migration::{ + probe_legacy_source, CancellationToken, MigrationEngine, NoCrashInjection, ProbeLimits, + }; + use openbitfun_product_domains::legacy_migration::{MigrationGroupId, MigrationSelection}; + use sha2::{Digest, Sha256}; + use std::collections::BTreeSet; + use std::path::Path; + + #[test] + fn settings_and_credentials_convert_without_staging_secrets() { + let temp = test_tempdir("settings-credentials"); + let roots = test_roots(temp.path()); + seed_source(&roots, true); + let source_before = sha256(&source_config_path(&roots)); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + + let target = read_target_config(&roots).unwrap(); + assert_eq!(target.app.language, "zh-CN"); + assert!(!target.app.auto_update); + assert_eq!(target.appearance.selection, "openbitfun-dark"); + assert_eq!( + target.ai.agent_profiles["coding_shared"].enabled_user_skills, + ["user::openbitfun::user-skill"] + ); + assert_eq!(target.ai.models[0].api_key, "fixture-secret"); + assert_eq!(sha256(&source_config_path(&roots)), source_before); + assert!(report.requires_reauthentication.is_empty()); + + let stage = fs::read_dir( + roots + .migration_root() + .join("runs") + .join(&plan.run_id) + .join("stage"), + ) + .unwrap() + .flat_map(|entry| walk_files(&entry.unwrap().path())) + .flat_map(|path| fs::read(path).unwrap()) + .collect::>(); + assert!(!String::from_utf8_lossy(&stage).contains("fixture-secret")); + } + + #[test] + fn explicit_target_values_win_and_are_reported() { + let temp = test_tempdir("settings-target-wins"); + let roots = test_roots(temp.path()); + seed_source(&roots, true); + let mut target = GlobalConfig::default(); + target.app.language = "en-US".to_string(); + target.ai.models.push(AIModelConfig { + id: "legacy-model".to_string(), + api_key: "target-secret".to_string(), + ..AIModelConfig::default() + }); + atomic_write_json(&target_config_path(&roots), &target).unwrap(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let selection = MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }; + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + assert!(plan + .conflicts + .iter() + .any(|conflict| conflict.code == "target_setting_wins")); + engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + let target = read_target_config(&roots).unwrap(); + assert_eq!(target.app.language, "en-US"); + assert_eq!(target.ai.models[0].api_key, "target-secret"); + } + + fn seed_source(roots: &MigrationRoots, with_secret: bool) { + let source = serde_json::json!({ + "app": {"language": "zh-CN", "auto_update": false, "telemetry": false}, + "appearance": {"theme_id": "bitfun-dark"}, + "ai": { + "agent_profiles": {"coding_shared": {"enabled_skills": ["user::bitfun::user-skill"]}}, + "models": [{ + "id": "legacy-model", + "name": "Legacy model", + "provider": "openai", + "model_name": "legacy-model", + "base_url": "https://example.invalid/v1", + "api_key": if with_secret { "fixture-secret" } else { "" } + }] + }, + "schema_version": 1, + "version": "0.2.19", + "last_modified": 0 + }); + atomic_write_json(&source_config_path(roots), &source).unwrap(); + } + + fn test_roots(root: &Path) -> MigrationRoots { + MigrationRoots { + legacy_user_root: root.join("legacy-user"), + legacy_home_root: root.join("legacy-home"), + legacy_skills_root: root.join("legacy-skills"), + legacy_ssh_root: root.join("legacy-ssh"), + target_user_root: root.join("target-user"), + target_home_root: root.join("target-home"), + target_skills_root: root.join("target-skills"), + target_ssh_root: root.join("target-ssh"), + } + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("E:/tmp")); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix(&format!("openbitfun-migration-{label}-")) + .tempdir_in(root) + .unwrap() + } + + fn sha256(path: &Path) -> Vec { + Sha256::digest(fs::read(path).unwrap()).to_vec() + } + + fn walk_files(root: &Path) -> Vec { + if root.is_file() { + return vec![root.to_path_buf()]; + } + fs::read_dir(root) + .unwrap() + .flat_map(|entry| walk_files(&entry.unwrap().path())) + .collect() + } +} diff --git a/src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs b/src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs new file mode 100644 index 0000000000..7277761714 --- /dev/null +++ b/src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs @@ -0,0 +1,1595 @@ +use super::common::{ + backup_domain_dir, backup_file_once, io_error, read_bounded_json, read_optional_bounded_json, + relative_display, restore_unverified_file, stage_domain_dir, validate_regular_file, +}; +use crate::service::session_projection_format::validate_runtime_event_log; +use crate::service::workspace::persistence::{ + current_workspace_storage_id, validate_workspace_persistence_data, WorkspacePersistenceData, + WORKSPACE_PERSISTENCE_FORMAT_VERSION, +}; +use crate::service::workspace::{PrimaryAssistantKey, WorkspaceInfo, WorkspaceKind}; +use openbitfun_core_types::product_identity::product_id; +use openbitfun_core_types::validate_session_id; +use openbitfun_legacy_migration::{ + atomic_write_bytes, atomic_write_json, DomainContext, DomainScan, LegacyDomainAdapter, + LegacyMigrationError, LegacyMigrationResult, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + ConflictResolution, FindingSeverity, MigrationConflict, MigrationDiagnostic, MigrationDomainId, + MigrationDomainResult, MigrationDomainState, ScanFinding, +}; +use openbitfun_services_core::session::{ + OfflineSessionBundle, OfflineSessionImportStore, SessionRelationship, StoredDialogTurnFile, + StoredSessionMetadataFile, SESSION_STORAGE_SCHEMA_VERSION, +}; +use openbitfun_services_core::workspace_identity::{ + canonicalize_local_workspace_root, normalize_remote_workspace_path, LOCAL_WORKSPACE_SSH_HOST, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +const MAX_SESSION_FILES: usize = 4096; +const MAX_SESSION_FILE_BYTES: u64 = 512 * 1024 * 1024; +const MAX_SESSION_BYTES: u64 = 4 * 1024 * 1024 * 1024; +const MAX_RUNTIME_EVENT_BYTES: u64 = 256 * 1024 * 1024; +const MAX_RUNTIME_DIRECTORIES: usize = 32_768; +const MAX_RUNTIME_DEPTH: usize = 16; + +const SESSION_ROOT_FILES: &[&str] = &[ + "state.json", + "prompt_cache.json", + "turn-catalog.json", + "token-anchors.json", + "session-revert.json", + "evidence-ledger.json", +]; +const SESSION_OWNED_DIRECTORIES: &[&str] = &["snapshots", "artifacts", "tool-results"]; + +pub(crate) struct WorkspaceSessionsAdapter; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SessionImportAction { + Import, + Duplicate, + TargetWins, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SessionManifestEntry { + pub(crate) runtime_relative: String, + pub(crate) session_id: String, + pub(crate) action: SessionImportAction, + pub(crate) expected_hash: String, + pub(crate) turn_ids: BTreeSet, + pub(crate) relationship: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RuntimeEventManifestEntry { + pub(crate) session_id: String, + pub(crate) action: SessionImportAction, + pub(crate) expected_hash: String, + pub(crate) turn_ids: BTreeSet, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WorkspaceSessionsManifest { + pub(crate) workspace_id_map: BTreeMap, + pub(crate) sessions: Vec, + pub(crate) runtime_events: Vec, + pub(crate) target_workspace_existed: bool, + pub(crate) target_workspace_hash: Option, + pub(crate) skipped_paths: Vec, +} + +#[derive(Debug, Deserialize)] +struct LegacyWorkspacePersistenceData { + workspaces: HashMap, + #[serde(default)] + opened_workspace_ids: Vec, + current_workspace_id: Option, + #[serde(default)] + recent_workspaces: Vec, + #[serde(default)] + recent_assistant_workspaces: Vec, + #[serde(default)] + primary_assistant_key: Option, + saved_at: chrono::DateTime, +} + +struct WorkspaceSessionsPlan { + workspace_data: WorkspacePersistenceData, + workspace_id_map: BTreeMap, + sessions: Vec, + runtime_events: Vec, + conflicts: Vec, + requires_relocation: Vec, + skipped_paths: Vec, + target_workspace_existed: bool, + target_workspace_hash: Option, + logical_bytes: u64, +} + +struct PlannedSession { + runtime_relative: PathBuf, + bundle: OfflineSessionBundle, + auxiliary_files: Vec<(PathBuf, PathBuf)>, + action: SessionImportAction, + expected_hash: String, +} + +struct PlannedRuntimeEvent { + session_id: String, + source_path: PathBuf, + action: SessionImportAction, + expected_hash: String, + turn_ids: BTreeSet, +} + +impl LegacyDomainAdapter for WorkspaceSessionsAdapter { + fn domain(&self) -> MigrationDomainId { + MigrationDomainId::WorkspaceSessions + } + + fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { + let plan = plan_workspace_sessions(roots)?; + Ok(DomainScan { + finding: ScanFinding { + domain: self.domain(), + code: "legacy_workspace_sessions_supported".to_string(), + severity: if plan.conflicts.is_empty() { + FindingSeverity::Info + } else { + FindingSeverity::Warning + }, + entity_count: (plan.workspace_id_map.len() + + plan.sessions.len() + + plan.runtime_events.len()) as u64, + logical_bytes: plan.logical_bytes, + source_schema: Some("bitfun.workspace-session.v1".to_string()), + migratable: true, + detail: format!( + "{} workspaces, {} Sessions, and {} runtime event logs are owner-readable", + plan.workspace_id_map.len(), + plan.sessions.len(), + plan.runtime_events.len() + ), + }, + conflicts: plan.conflicts, + target_schema: Some("openbitfun.workspace-session.current".to_string()), + dependencies: Vec::new(), + }) + } + + fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { + let plan = plan_workspace_sessions(context.roots)?; + let domain_root = stage_domain_dir(context, "workspace-sessions"); + atomic_write_json( + &domain_root.join("workspace_data.json"), + &plan.workspace_data, + )?; + + let runtime = offline_runtime()?; + for session in &plan.sessions { + if session.action != SessionImportAction::Import { + continue; + } + let sessions_root = domain_root.join("home").join(&session.runtime_relative); + let store = OfflineSessionImportStore::new(&sessions_root); + runtime + .block_on(store.write_bundle(&session.bundle)) + .map_err(|error| owner_error("write staged Session", error))?; + let staged_session = sessions_root.join(&session.bundle.metadata.session_id); + for (relative, source) in &session.auxiliary_files { + let bytes = fs::read(source).map_err(|error| io_error(source, error))?; + atomic_write_bytes(&staged_session.join(relative), &bytes)?; + } + require_tree_hash(&staged_session, &session.expected_hash)?; + } + + for event in &plan.runtime_events { + if event.action != SessionImportAction::Import { + continue; + } + let bytes = fs::read(&event.source_path) + .map_err(|error| io_error(&event.source_path, error))?; + atomic_write_bytes( + &domain_root + .join("runtime-events") + .join(format!("{}.jsonl", event.session_id)), + &bytes, + )?; + } + + let manifest = WorkspaceSessionsManifest { + workspace_id_map: plan.workspace_id_map, + sessions: plan.sessions.iter().map(session_manifest_entry).collect(), + runtime_events: plan + .runtime_events + .iter() + .map(|event| RuntimeEventManifestEntry { + session_id: event.session_id.clone(), + action: event.action, + expected_hash: event.expected_hash.clone(), + turn_ids: event.turn_ids.clone(), + }) + .collect(), + target_workspace_existed: plan.target_workspace_existed, + target_workspace_hash: plan.target_workspace_hash, + skipped_paths: plan.skipped_paths, + }; + atomic_write_json(&workspace_sessions_manifest_path(context), &manifest)?; + + let imported = manifest + .sessions + .iter() + .filter(|entry| entry.action == SessionImportAction::Import) + .count() + + manifest + .runtime_events + .iter() + .filter(|entry| entry.action == SessionImportAction::Import) + .count() + + manifest.workspace_id_map.len(); + let skipped = manifest + .sessions + .iter() + .filter(|entry| entry.action != SessionImportAction::Import) + .count() + + manifest + .runtime_events + .iter() + .filter(|entry| entry.action != SessionImportAction::Import) + .count(); + let warnings = manifest + .skipped_paths + .iter() + .map(|path| MigrationDiagnostic { + code: "session_path_not_migrated".to_string(), + severity: FindingSeverity::Warning, + domain: Some(self.domain()), + relative_path: Some(path.clone()), + message: "A non-owned or rebuildable Session path was left in the legacy source" + .to_string(), + action: Some("Review the migration report before removing legacy data".to_string()), + }) + .collect(); + Ok(MigrationDomainResult { + domain: self.domain(), + state: MigrationDomainState::Staged, + imported: imported as u64, + skipped: skipped as u64, + conflicts: plan.conflicts.len() as u64, + warnings, + requires_relocation: plan.requires_relocation, + ..MigrationDomainResult::default() + }) + } + + fn validate_stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_workspace_sessions_manifest(context)?; + let domain_root = stage_domain_dir(context, "workspace-sessions"); + let workspace_data: WorkspacePersistenceData = read_bounded_json( + &context.layout.stage_root(), + &domain_root.join("workspace_data.json"), + )?; + validate_workspace_persistence_data( + &workspace_data, + &context.roots.target_user_root.join("data/miniapps"), + ) + .map_err(|error| owner_error("validate staged Workspace registry", error))?; + + let runtime = offline_runtime()?; + for entry in imported_sessions(&manifest) { + let sessions_root = domain_root + .join("home") + .join(path_from_manifest(&entry.runtime_relative)?); + let store = OfflineSessionImportStore::new(&sessions_root); + let bundle = runtime + .block_on(store.load_bundle(&entry.session_id)) + .map_err(|error| owner_error("read staged Session", error))? + .ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!( + "staged Session is missing: {}", + entry.session_id + )) + })?; + bundle + .validate() + .map_err(|error| owner_error("validate staged Session", error))?; + require_tree_hash(&sessions_root.join(&entry.session_id), &entry.expected_hash)?; + } + for entry in imported_runtime_events(&manifest) { + let path = domain_root + .join("runtime-events") + .join(format!("{}.jsonl", entry.session_id)); + validate_runtime_event_log(&path, &entry.session_id) + .map_err(|error| owner_error("validate staged runtime event log", error))?; + require_file_hash(&path, &entry.expected_hash)?; + } + validate_session_relationship_closure(&manifest) + } + + fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_workspace_sessions_manifest(context)?; + let domain_root = stage_domain_dir(context, "workspace-sessions"); + let target_workspace = target_workspace_data_path(context.roots); + let staged_workspace = domain_root.join("workspace_data.json"); + let staged_workspace_hash = hash_file(&staged_workspace)?; + let workspace_already_applied = if target_workspace.exists() { + validate_regular_file(&context.roots.target_user_root, &target_workspace)?; + hash_file(&target_workspace)? == staged_workspace_hash + } else { + false + }; + if !workspace_already_applied { + verify_planned_file_state( + &target_workspace, + manifest.target_workspace_existed, + manifest.target_workspace_hash.as_deref(), + )?; + backup_file_once( + &target_workspace, + &backup_domain_dir(context, "workspace-sessions").join("workspace_data.json"), + )?; + let workspace_bytes = + fs::read(&staged_workspace).map_err(|error| io_error(&staged_workspace, error))?; + atomic_write_bytes(&target_workspace, &workspace_bytes)?; + } + + for entry in imported_sessions(&manifest) { + let relative = path_from_manifest(&entry.runtime_relative)?; + let staged = domain_root + .join("home") + .join(&relative) + .join(&entry.session_id); + let target = context + .roots + .target_home_root + .join(&relative) + .join(&entry.session_id); + install_directory_idempotent( + &staged, + &target, + &entry.expected_hash, + &context.plan.run_id, + )?; + } + for entry in imported_runtime_events(&manifest) { + let staged = domain_root + .join("runtime-events") + .join(format!("{}.jsonl", entry.session_id)); + let target = context + .roots + .target_home_root + .join("runtime-events") + .join(format!("{}.jsonl", entry.session_id)); + install_file_idempotent(&staged, &target, &entry.expected_hash)?; + } + Ok(()) + } + + fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let manifest = read_workspace_sessions_manifest(context)?; + let expected: WorkspacePersistenceData = read_bounded_json( + &context.layout.stage_root(), + &stage_domain_dir(context, "workspace-sessions").join("workspace_data.json"), + )?; + let actual: WorkspacePersistenceData = read_bounded_json( + &context.roots.target_user_root, + &target_workspace_data_path(context.roots), + )?; + validate_workspace_persistence_data( + &actual, + &context.roots.target_user_root.join("data/miniapps"), + ) + .map_err(|error| owner_error("validate committed Workspace registry", error))?; + if serde_json::to_value(&expected).map_err(json_error)? + != serde_json::to_value(&actual).map_err(json_error)? + { + return Err(LegacyMigrationError::InvalidRequest( + "committed Workspace registry differs from the staged owner output".to_string(), + )); + } + + let runtime = offline_runtime()?; + for entry in imported_sessions(&manifest) { + let relative = path_from_manifest(&entry.runtime_relative)?; + let sessions_root = context.roots.target_home_root.join(&relative); + let store = OfflineSessionImportStore::new(&sessions_root); + let bundle = runtime + .block_on(store.load_bundle(&entry.session_id)) + .map_err(|error| owner_error("read committed Session", error))? + .ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!( + "committed Session is missing: {}", + entry.session_id + )) + })?; + bundle + .validate() + .map_err(|error| owner_error("validate committed Session", error))?; + require_tree_hash(&sessions_root.join(&entry.session_id), &entry.expected_hash)?; + } + for entry in imported_runtime_events(&manifest) { + let path = context + .roots + .target_home_root + .join("runtime-events") + .join(format!("{}.jsonl", entry.session_id)); + validate_runtime_event_log(&path, &entry.session_id) + .map_err(|error| owner_error("validate committed runtime event log", error))?; + require_file_hash(&path, &entry.expected_hash)?; + } + Ok(()) + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + let Some(manifest) = read_optional_bounded_json::( + &context.layout.stage_root(), + &workspace_sessions_manifest_path(context), + )? + else { + return Ok(()); + }; + restore_unverified_file( + &target_workspace_data_path(context.roots), + &backup_domain_dir(context, "workspace-sessions").join("workspace_data.json"), + manifest.target_workspace_existed, + )?; + let domain_root = stage_domain_dir(context, "workspace-sessions"); + for entry in imported_sessions(&manifest) { + let relative = path_from_manifest(&entry.runtime_relative)?; + remove_directory_if_matches( + &domain_root + .join("home") + .join(&relative) + .join(&entry.session_id), + &context + .roots + .target_home_root + .join(relative) + .join(&entry.session_id), + )?; + } + for entry in imported_runtime_events(&manifest) { + remove_file_if_matches( + &domain_root + .join("runtime-events") + .join(format!("{}.jsonl", entry.session_id)), + &context + .roots + .target_home_root + .join("runtime-events") + .join(format!("{}.jsonl", entry.session_id)), + )?; + } + Ok(()) + } +} + +fn plan_workspace_sessions(roots: &MigrationRoots) -> LegacyMigrationResult { + let source_workspace_path = source_workspace_data_path(roots); + let legacy: LegacyWorkspacePersistenceData = + read_bounded_json(&roots.legacy_user_root, &source_workspace_path)?; + let target_workspace_path = target_workspace_data_path(roots); + let target = read_optional_bounded_json::( + &roots.target_user_root, + &target_workspace_path, + )?; + if let Some(target) = &target { + validate_workspace_persistence_data(target, &roots.target_user_root.join("data/miniapps")) + .map_err(|error| owner_error("read current Workspace registry", error))?; + } + + let mut workspace_id_map = BTreeMap::new(); + let mut converted = Vec::new(); + let mut source_workspace_ids = legacy.workspaces.keys().cloned().collect::>(); + source_workspace_ids.sort(); + let mut requires_relocation = Vec::new(); + for source_id in source_workspace_ids { + let mut workspace = legacy.workspaces[&source_id].clone(); + normalize_legacy_workspace_for_current(&mut workspace)?; + let target_id = current_workspace_storage_id(&workspace) + .map_err(|error| owner_error("convert legacy Workspace id", error))?; + workspace.id = target_id.clone(); + if workspace.workspace_kind != WorkspaceKind::Remote && !workspace.root_path.exists() { + requires_relocation.push(target_id.clone()); + } + workspace_id_map.insert(source_id, target_id.clone()); + converted.push((target_id, workspace)); + } + + let mut conflicts = Vec::new(); + let mut output_workspaces = target + .as_ref() + .map(|value| value.workspaces.clone()) + .unwrap_or_default(); + for (target_id, workspace) in converted { + if let Some(existing) = output_workspaces.get(&target_id) { + if serde_json::to_value(existing).map_err(json_error)? + != serde_json::to_value(&workspace).map_err(json_error)? + { + conflicts.push(MigrationConflict { + domain: MigrationDomainId::WorkspaceSessions, + code: "workspace_target_wins".to_string(), + source_summary: format!("legacy Workspace {target_id}"), + target_summary: format!("current Workspace {target_id}"), + resolution: ConflictResolution::TargetWins, + }); + } + } else { + output_workspaces.insert(target_id, workspace); + } + } + + let mut opened_workspace_ids = merge_reference_list( + target.as_ref().map(|value| &value.opened_workspace_ids), + &legacy.opened_workspace_ids, + &workspace_id_map, + &output_workspaces, + ); + let recent_workspaces = merge_reference_list( + target.as_ref().map(|value| &value.recent_workspaces), + &legacy.recent_workspaces, + &workspace_id_map, + &output_workspaces, + ); + let recent_assistant_workspaces = merge_reference_list( + target + .as_ref() + .map(|value| &value.recent_assistant_workspaces), + &legacy.recent_assistant_workspaces, + &workspace_id_map, + &output_workspaces, + ); + let source_current = legacy + .current_workspace_id + .as_ref() + .and_then(|id| workspace_id_map.get(id)) + .filter(|id| output_workspaces.contains_key(*id)) + .cloned(); + let current_workspace_id = target + .as_ref() + .and_then(|value| value.current_workspace_id.clone()) + .or(source_current); + if let Some(current_id) = current_workspace_id.as_ref() { + if !opened_workspace_ids.iter().any(|id| id == current_id) { + opened_workspace_ids.push(current_id.clone()); + } + } + let workspace_data = WorkspacePersistenceData { + format_version: WORKSPACE_PERSISTENCE_FORMAT_VERSION, + product_id: product_id().to_string(), + workspaces: output_workspaces, + opened_workspace_ids, + current_workspace_id, + recent_workspaces, + recent_assistant_workspaces, + primary_assistant_key: target + .as_ref() + .and_then(|value| value.primary_assistant_key.clone()) + .or(legacy.primary_assistant_key), + saved_at: target + .as_ref() + .map(|value| value.saved_at) + .unwrap_or(legacy.saved_at), + }; + validate_workspace_persistence_data( + &workspace_data, + &roots.target_user_root.join("data/miniapps"), + ) + .map_err(|error| owner_error("convert legacy Workspace registry", error))?; + + let target_sessions = index_target_sessions(roots)?; + let (sessions, mut skipped_paths) = plan_sessions(roots, &target_sessions, &mut conflicts)?; + let runtime_events = plan_runtime_events(roots, &sessions, &mut conflicts, &mut skipped_paths)?; + let session_bytes = sessions.iter().try_fold(0u64, |total, session| { + expected_session_bytes(session).map(|bytes| total.saturating_add(bytes)) + })?; + let event_bytes = runtime_events + .iter() + .map(|event| { + fs::metadata(&event.source_path) + .map(|metadata| metadata.len()) + .unwrap_or(0) + }) + .sum::(); + let workspace_bytes = fs::metadata(&source_workspace_path) + .map_err(|error| io_error(&source_workspace_path, error))? + .len(); + let target_workspace_existed = target_workspace_path.exists(); + let target_workspace_hash = target_workspace_existed + .then(|| hash_file(&target_workspace_path)) + .transpose()?; + + Ok(WorkspaceSessionsPlan { + workspace_data, + workspace_id_map, + sessions, + runtime_events, + conflicts, + requires_relocation, + skipped_paths, + target_workspace_existed, + target_workspace_hash, + logical_bytes: workspace_bytes + .saturating_add(session_bytes) + .saturating_add(event_bytes), + }) +} + +fn normalize_legacy_workspace_for_current( + workspace: &mut WorkspaceInfo, +) -> LegacyMigrationResult<()> { + if workspace.workspace_kind == WorkspaceKind::Remote { + let normalized = normalize_remote_workspace_path(&workspace.root_path.to_string_lossy()); + if !normalized.starts_with('/') { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "remote Workspace {} does not use an absolute POSIX root", + workspace.id + ))); + } + workspace.root_path = PathBuf::from(normalized); + } else { + workspace.metadata.insert( + "sshHost".to_string(), + serde_json::Value::String(LOCAL_WORKSPACE_SSH_HOST.to_string()), + ); + if workspace.root_path.exists() { + let (canonical, _) = canonicalize_local_workspace_root(&workspace.root_path) + .map_err(|error| owner_error("canonicalize legacy Workspace root", error))?; + workspace.root_path = canonical; + } + } + Ok(()) +} + +fn plan_sessions( + roots: &MigrationRoots, + target_sessions: &HashMap>, + conflicts: &mut Vec, +) -> LegacyMigrationResult<(Vec, Vec)> { + let session_roots = find_session_roots(&roots.legacy_home_root)?; + let mut sessions = Vec::new(); + let mut skipped_paths = Vec::new(); + let mut source_ids = HashMap::::new(); + for sessions_root in session_roots { + let runtime_relative = sessions_root + .strip_prefix(&roots.legacy_home_root) + .map_err(|_| LegacyMigrationError::PathEscape(sessions_root.clone()))? + .to_path_buf(); + for session_dir in child_directories(&sessions_root)? { + let session_id = file_name(&session_dir)?; + validate_session_id(&session_id).map_err(|error| { + LegacyMigrationError::UnsupportedSource(format!( + "legacy Session id is unsafe: {error}" + )) + })?; + let metadata_path = session_dir.join("metadata.json"); + match fs::symlink_metadata(&metadata_path) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + skipped_paths.push(relative_display(&roots.legacy_home_root, &session_dir)); + continue; + } + Err(error) => return Err(io_error(&metadata_path, error)), + } + let metadata_file: StoredSessionMetadataFile = + read_bounded_json(&roots.legacy_home_root, &metadata_path)?; + if metadata_file.schema_version > SESSION_STORAGE_SCHEMA_VERSION { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "Session {session_id} uses unsupported schema {}", + metadata_file.schema_version + ))); + } + if metadata_file.metadata.session_id != session_id { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "Session directory id does not match its metadata: {session_id}" + ))); + } + let turns = read_session_turns(&roots.legacy_home_root, &session_dir, &session_id)?; + let bundle = OfflineSessionBundle { + metadata: metadata_file.metadata, + turns, + }; + bundle + .validate() + .map_err(|error| owner_error("validate legacy Session", error))?; + let auxiliary_files = collect_auxiliary_session_files( + &roots.legacy_home_root, + &session_dir, + &mut skipped_paths, + )?; + let expected_hash = expected_session_hash(&bundle, &auxiliary_files)?; + if auxiliary_files + .len() + .saturating_add(bundle.turns.len()) + .saturating_add(1) + > MAX_SESSION_FILES + { + return Err(LegacyMigrationError::ResourceLimit(format!( + "Session contains more than {MAX_SESSION_FILES} files: {}", + session_dir.display() + ))); + } + if expected_bundle_bytes(&bundle, &auxiliary_files)? > MAX_SESSION_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "Session exceeds {MAX_SESSION_BYTES} bytes: {}", + session_dir.display() + ))); + } + if let Some(previous_hash) = + source_ids.insert(session_id.clone(), expected_hash.clone()) + { + if previous_hash != expected_hash { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "legacy Session id appears with different contents: {session_id}" + ))); + } + skipped_paths.push(relative_display(&roots.legacy_home_root, &session_dir)); + continue; + } + + let target_same_path = roots + .target_home_root + .join(&runtime_relative) + .join(&session_id); + let action = match target_sessions.get(&session_id) { + None => SessionImportAction::Import, + Some(paths) + if paths.len() == 1 + && paths[0] == target_same_path + && hash_tree(&target_same_path)? == expected_hash => + { + SessionImportAction::Duplicate + } + Some(_) => { + conflicts.push(MigrationConflict { + domain: MigrationDomainId::WorkspaceSessions, + code: "session_target_wins".to_string(), + source_summary: format!("legacy Session {session_id}"), + target_summary: format!("current Session {session_id}"), + resolution: ConflictResolution::TargetWins, + }); + SessionImportAction::TargetWins + } + }; + sessions.push(PlannedSession { + runtime_relative: runtime_relative.clone(), + bundle, + auxiliary_files, + action, + expected_hash, + }); + } + } + sessions.sort_by(|left, right| { + (&left.runtime_relative, &left.bundle.metadata.session_id) + .cmp(&(&right.runtime_relative, &right.bundle.metadata.session_id)) + }); + Ok((sessions, skipped_paths)) +} + +fn plan_runtime_events( + roots: &MigrationRoots, + sessions: &[PlannedSession], + conflicts: &mut Vec, + skipped_paths: &mut Vec, +) -> LegacyMigrationResult> { + let source_root = roots.legacy_home_root.join("runtime-events"); + if !source_root.exists() { + return Ok(Vec::new()); + } + reject_linked_directory(&source_root)?; + let known_sessions = sessions + .iter() + .map(|session| (session.bundle.metadata.session_id.as_str(), session.action)) + .collect::>(); + let mut planned = Vec::new(); + for entry in read_dir_sorted(&source_root)? { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(&path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path)); + } + if !metadata.is_file() || path.extension().and_then(|value| value.to_str()) != Some("jsonl") + { + skipped_paths.push(relative_display(&roots.legacy_home_root, &path)); + continue; + } + if metadata.len() > MAX_RUNTIME_EVENT_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "runtime event log exceeds {MAX_RUNTIME_EVENT_BYTES} bytes: {}", + relative_display(&roots.legacy_home_root, &path) + ))); + } + validate_regular_file(&roots.legacy_home_root, &path)?; + let session_id = path + .file_stem() + .and_then(|value| value.to_str()) + .ok_or_else(|| { + LegacyMigrationError::UnsupportedSource( + "runtime event log name is not valid UTF-8".to_string(), + ) + })? + .to_string(); + let Some(session_action) = known_sessions.get(session_id.as_str()).copied() else { + skipped_paths.push(relative_display(&roots.legacy_home_root, &path)); + continue; + }; + let summary = validate_runtime_event_log(&path, &session_id) + .map_err(|error| owner_error("read legacy runtime event log", error))?; + let expected_hash = hash_file(&path)?; + let target = roots + .target_home_root + .join("runtime-events") + .join(format!("{session_id}.jsonl")); + let action = if session_action == SessionImportAction::TargetWins { + SessionImportAction::TargetWins + } else if !target.exists() { + SessionImportAction::Import + } else if hash_file(&target)? == expected_hash { + SessionImportAction::Duplicate + } else { + conflicts.push(MigrationConflict { + domain: MigrationDomainId::WorkspaceSessions, + code: "runtime_event_target_wins".to_string(), + source_summary: format!("legacy runtime event log for {session_id}"), + target_summary: format!("current runtime event log for {session_id}"), + resolution: ConflictResolution::TargetWins, + }); + SessionImportAction::TargetWins + }; + planned.push(PlannedRuntimeEvent { + session_id, + source_path: path, + action, + expected_hash, + turn_ids: summary.turn_ids, + }); + } + planned.sort_by(|left, right| left.session_id.cmp(&right.session_id)); + Ok(planned) +} + +fn read_session_turns( + legacy_home_root: &Path, + session_dir: &Path, + session_id: &str, +) -> LegacyMigrationResult> { + let turns_dir = session_dir.join("turns"); + if !turns_dir.exists() { + return Ok(Vec::new()); + } + reject_linked_directory(&turns_dir)?; + let mut turns = Vec::new(); + for entry in read_dir_sorted(&turns_dir)? { + let path = entry.path(); + validate_regular_file(legacy_home_root, &path)?; + let file_name = file_name(&path)?; + let file_index = file_name + .strip_prefix("turn-") + .and_then(|value| value.strip_suffix(".json")) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| { + LegacyMigrationError::UnsupportedSource(format!( + "unsupported legacy Turn filename: {file_name}" + )) + })?; + let stored: StoredDialogTurnFile = read_bounded_json(legacy_home_root, &path)?; + if stored.schema_version > SESSION_STORAGE_SCHEMA_VERSION { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "Session {session_id} Turn uses unsupported schema {}", + stored.schema_version + ))); + } + if stored.turn.session_id != session_id || stored.turn.turn_index != file_index { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "legacy Turn identity does not match its storage path in Session {session_id}" + ))); + } + turns.push(stored.turn); + } + turns.sort_by_key(|turn| turn.turn_index); + Ok(turns) +} + +fn collect_auxiliary_session_files( + legacy_home_root: &Path, + session_dir: &Path, + skipped_paths: &mut Vec, +) -> LegacyMigrationResult> { + let mut files = Vec::new(); + for entry in read_dir_sorted(session_dir)? { + let path = entry.path(); + let name = file_name(&path)?; + let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(&path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path)); + } + if metadata.is_file() { + if name == "metadata.json" { + continue; + } + if SESSION_ROOT_FILES.contains(&name.as_str()) { + validate_owned_file(legacy_home_root, session_dir, &path, &mut files)?; + } else { + skipped_paths.push(relative_display(legacy_home_root, &path)); + } + } else if metadata.is_dir() { + if name == "turns" { + continue; + } + if SESSION_OWNED_DIRECTORIES.contains(&name.as_str()) { + collect_owned_directory(legacy_home_root, session_dir, &path, 0, &mut files)?; + } else { + skipped_paths.push(relative_display(legacy_home_root, &path)); + } + } else { + skipped_paths.push(relative_display(legacy_home_root, &path)); + } + } + enforce_session_limits(session_dir, &files)?; + Ok(files) +} + +fn collect_owned_directory( + legacy_home_root: &Path, + session_dir: &Path, + directory: &Path, + depth: usize, + files: &mut Vec<(PathBuf, PathBuf)>, +) -> LegacyMigrationResult<()> { + if depth > MAX_RUNTIME_DEPTH { + return Err(LegacyMigrationError::ResourceLimit(format!( + "Session directory depth exceeds {MAX_RUNTIME_DEPTH}: {}", + relative_display(legacy_home_root, directory) + ))); + } + reject_linked_directory(directory)?; + for entry in read_dir_sorted(directory)? { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(&path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path)); + } + if metadata.is_dir() { + collect_owned_directory(legacy_home_root, session_dir, &path, depth + 1, files)?; + } else if metadata.is_file() { + validate_owned_file(legacy_home_root, session_dir, &path, files)?; + } + } + Ok(()) +} + +fn validate_owned_file( + legacy_home_root: &Path, + session_dir: &Path, + path: &Path, + files: &mut Vec<(PathBuf, PathBuf)>, +) -> LegacyMigrationResult<()> { + validate_regular_file(legacy_home_root, path)?; + let relative = path + .strip_prefix(session_dir) + .map_err(|_| LegacyMigrationError::PathEscape(path.to_path_buf()))? + .to_path_buf(); + files.push((relative, path.to_path_buf())); + Ok(()) +} + +fn enforce_session_limits( + session_dir: &Path, + files: &[(PathBuf, PathBuf)], +) -> LegacyMigrationResult<()> { + let mut total = 0u64; + for (_, path) in files { + let size = fs::metadata(path) + .map_err(|error| io_error(path, error))? + .len(); + if size > MAX_SESSION_FILE_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "Session file exceeds {MAX_SESSION_FILE_BYTES} bytes: {}", + path.display() + ))); + } + total = total.saturating_add(size); + } + if total > MAX_SESSION_BYTES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "Session exceeds {MAX_SESSION_BYTES} bytes: {}", + session_dir.display() + ))); + } + Ok(()) +} + +fn index_target_sessions( + roots: &MigrationRoots, +) -> LegacyMigrationResult>> { + let mut by_id = HashMap::>::new(); + for sessions_root in find_session_roots(&roots.target_home_root)? { + for session_dir in child_directories(&sessions_root)? { + by_id + .entry(file_name(&session_dir)?) + .or_default() + .push(session_dir); + } + } + for paths in by_id.values_mut() { + paths.sort(); + } + Ok(by_id) +} + +fn find_session_roots(home_root: &Path) -> LegacyMigrationResult> { + let mut found = Vec::new(); + let mut visited = 0usize; + for name in ["projects", "remote_ssh", "personal_assistant"] { + let root = home_root.join(name); + if root.exists() { + find_session_roots_recursive(&root, 0, &mut visited, &mut found)?; + } + } + found.sort(); + Ok(found) +} + +fn find_session_roots_recursive( + directory: &Path, + depth: usize, + visited: &mut usize, + found: &mut Vec, +) -> LegacyMigrationResult<()> { + if depth > MAX_RUNTIME_DEPTH { + return Err(LegacyMigrationError::ResourceLimit(format!( + "workspace runtime depth exceeds {MAX_RUNTIME_DEPTH}: {}", + directory.display() + ))); + } + *visited = visited.saturating_add(1); + if *visited > MAX_RUNTIME_DIRECTORIES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "workspace runtime contains more than {MAX_RUNTIME_DIRECTORIES} directories" + ))); + } + reject_linked_directory(directory)?; + if directory.file_name().and_then(|value| value.to_str()) == Some("sessions") { + found.push(directory.to_path_buf()); + return Ok(()); + } + for child in child_directories(directory)? { + find_session_roots_recursive(&child, depth + 1, visited, found)?; + } + Ok(()) +} + +fn child_directories(directory: &Path) -> LegacyMigrationResult> { + let mut paths = Vec::new(); + for entry in read_dir_sorted(directory)? { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(&path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path)); + } + if metadata.is_dir() { + paths.push(path); + } + } + Ok(paths) +} + +fn read_dir_sorted(directory: &Path) -> LegacyMigrationResult> { + let mut entries = fs::read_dir(directory) + .map_err(|error| io_error(directory, error))? + .collect::, _>>() + .map_err(|error| io_error(directory, error))?; + entries.sort_by_key(|entry| entry.file_name()); + Ok(entries) +} + +fn reject_linked_directory(path: &Path) -> LegacyMigrationResult<()> { + let metadata = fs::symlink_metadata(path).map_err(|error| io_error(path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path.to_path_buf())); + } + if !metadata.is_dir() { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "expected a directory at {}", + path.display() + ))); + } + Ok(()) +} + +#[cfg(windows)] +fn is_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + metadata.file_attributes() & 0x0400 != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +fn merge_reference_list( + target: Option<&Vec>, + source: &[String], + id_map: &BTreeMap, + workspaces: &HashMap, +) -> Vec { + let mut merged = Vec::new(); + let mut seen = HashSet::new(); + for id in target + .into_iter() + .flatten() + .cloned() + .chain(source.iter().filter_map(|id| id_map.get(id)).cloned()) + { + if workspaces.contains_key(&id) && seen.insert(id.clone()) { + merged.push(id); + } + } + merged +} + +fn expected_session_hash( + bundle: &OfflineSessionBundle, + auxiliary_files: &[(PathBuf, PathBuf)], +) -> LegacyMigrationResult { + let mut entries = Vec::new(); + entries.push(( + PathBuf::from("metadata.json"), + serde_json::to_vec(&StoredSessionMetadataFile::new(bundle.metadata.clone())) + .map_err(json_error)?, + )); + for turn in &bundle.turns { + entries.push(( + PathBuf::from("turns").join(format!("turn-{:04}.json", turn.turn_index)), + serde_json::to_vec(&StoredDialogTurnFile::new(turn.clone())).map_err(json_error)?, + )); + } + for (relative, source) in auxiliary_files { + entries.push(( + relative.clone(), + fs::read(source).map_err(|error| io_error(source, error))?, + )); + } + Ok(hash_entries(entries)) +} + +fn expected_session_bytes(session: &PlannedSession) -> LegacyMigrationResult { + expected_bundle_bytes(&session.bundle, &session.auxiliary_files) +} + +fn expected_bundle_bytes( + bundle: &OfflineSessionBundle, + auxiliary_files: &[(PathBuf, PathBuf)], +) -> LegacyMigrationResult { + let metadata_bytes = + serde_json::to_vec(&StoredSessionMetadataFile::new(bundle.metadata.clone())) + .map_err(json_error)? + .len() as u64; + let turn_bytes = bundle.turns.iter().try_fold(0u64, |total, turn| { + serde_json::to_vec(&StoredDialogTurnFile::new(turn.clone())) + .map(|bytes| total.saturating_add(bytes.len() as u64)) + .map_err(json_error) + })?; + let auxiliary_bytes = auxiliary_files.iter().try_fold( + 0u64, + |total, (_, path)| -> LegacyMigrationResult { + let bytes = fs::metadata(path) + .map_err(|error| io_error(path, error))? + .len(); + Ok(total.saturating_add(bytes)) + }, + )?; + Ok(metadata_bytes + .saturating_add(turn_bytes) + .saturating_add(auxiliary_bytes)) +} + +pub(crate) fn target_wins_session_ids( + roots: &MigrationRoots, +) -> LegacyMigrationResult> { + Ok(plan_workspace_sessions(roots)? + .sessions + .into_iter() + .filter(|session| session.action == SessionImportAction::TargetWins) + .map(|session| session.bundle.metadata.session_id) + .collect()) +} + +fn hash_entries(mut entries: Vec<(PathBuf, Vec)>) -> String { + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let mut hasher = Sha256::new(); + for (relative, bytes) in entries { + hasher.update(relative.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update([0]); + hasher.update(bytes); + hasher.update([0]); + } + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +fn hash_tree(root: &Path) -> LegacyMigrationResult { + let mut entries = Vec::new(); + collect_tree_entries(root, root, 0, &mut entries)?; + Ok(hash_entries(entries)) +} + +fn collect_tree_entries( + root: &Path, + directory: &Path, + depth: usize, + entries: &mut Vec<(PathBuf, Vec)>, +) -> LegacyMigrationResult<()> { + if depth > MAX_RUNTIME_DEPTH { + return Err(LegacyMigrationError::ResourceLimit(format!( + "target tree depth exceeds {MAX_RUNTIME_DEPTH}: {}", + root.display() + ))); + } + reject_linked_directory(directory)?; + for entry in read_dir_sorted(directory)? { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(&path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path)); + } + if metadata.is_dir() { + collect_tree_entries(root, &path, depth + 1, entries)?; + } else if metadata.is_file() { + let relative = path + .strip_prefix(root) + .map_err(|_| LegacyMigrationError::PathEscape(path.clone()))? + .to_path_buf(); + entries.push(( + relative, + fs::read(&path).map_err(|error| io_error(&path, error))?, + )); + } + if entries.len() > MAX_SESSION_FILES { + return Err(LegacyMigrationError::ResourceLimit(format!( + "target tree contains more than {MAX_SESSION_FILES} files: {}", + root.display() + ))); + } + } + Ok(()) +} + +fn require_tree_hash(path: &Path, expected: &str) -> LegacyMigrationResult<()> { + let actual = hash_tree(path)?; + if actual != expected { + return Err(LegacyMigrationError::InvalidRequest(format!( + "Session tree hash mismatch at {}", + path.display() + ))); + } + Ok(()) +} + +fn hash_file(path: &Path) -> LegacyMigrationResult { + fs::read(path) + .map(|bytes| format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) + .map_err(|error| io_error(path, error)) +} + +fn require_file_hash(path: &Path, expected: &str) -> LegacyMigrationResult<()> { + if hash_file(path)? != expected { + return Err(LegacyMigrationError::InvalidRequest(format!( + "file hash mismatch at {}", + path.display() + ))); + } + Ok(()) +} + +fn install_directory_idempotent( + staged: &Path, + target: &Path, + expected_hash: &str, + run_id: &str, +) -> LegacyMigrationResult<()> { + if target.exists() { + if hash_tree(target)? == expected_hash { + return Ok(()); + } + return Err(LegacyMigrationError::InvalidRequest(format!( + "target changed after planning: {}", + target.display() + ))); + } + let parent = target.parent().ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!("target has no parent: {}", target.display())) + })?; + fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; + let temp = parent.join(format!( + ".migration-{}-{}", + safe_component(run_id), + target.file_name().unwrap_or_default().to_string_lossy() + )); + if temp.exists() { + fs::remove_dir_all(&temp).map_err(|error| io_error(&temp, error))?; + } + let install_result = copy_tree(staged, &temp) + .and_then(|()| fs::rename(&temp, target).map_err(|error| io_error(target, error))); + if install_result.is_err() && temp.exists() { + fs::remove_dir_all(&temp).map_err(|error| io_error(&temp, error))?; + } + install_result +} + +fn copy_tree(source: &Path, target: &Path) -> LegacyMigrationResult<()> { + reject_linked_directory(source)?; + fs::create_dir_all(target).map_err(|error| io_error(target, error))?; + for entry in read_dir_sorted(source)? { + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + let metadata = + fs::symlink_metadata(&source_path).map_err(|error| io_error(&source_path, error))?; + if metadata.file_type().is_symlink() || is_reparse_point(&metadata) { + return Err(LegacyMigrationError::LinkedPath(source_path)); + } + if metadata.is_dir() { + copy_tree(&source_path, &target_path)?; + } else if metadata.is_file() { + let bytes = fs::read(&source_path).map_err(|error| io_error(&source_path, error))?; + atomic_write_bytes(&target_path, &bytes)?; + } + } + Ok(()) +} + +fn install_file_idempotent( + staged: &Path, + target: &Path, + expected_hash: &str, +) -> LegacyMigrationResult<()> { + if target.exists() { + if hash_file(target)? == expected_hash { + return Ok(()); + } + return Err(LegacyMigrationError::InvalidRequest(format!( + "target changed after planning: {}", + target.display() + ))); + } + let bytes = fs::read(staged).map_err(|error| io_error(staged, error))?; + atomic_write_bytes(target, &bytes) +} + +fn remove_directory_if_matches(staged: &Path, target: &Path) -> LegacyMigrationResult<()> { + if staged.exists() && target.exists() && hash_tree(staged)? == hash_tree(target)? { + fs::remove_dir_all(target).map_err(|error| io_error(target, error))?; + } + Ok(()) +} + +fn remove_file_if_matches(staged: &Path, target: &Path) -> LegacyMigrationResult<()> { + if staged.exists() && target.exists() && hash_file(staged)? == hash_file(target)? { + fs::remove_file(target).map_err(|error| io_error(target, error))?; + } + Ok(()) +} + +fn verify_planned_file_state( + path: &Path, + expected_exists: bool, + expected_hash: Option<&str>, +) -> LegacyMigrationResult<()> { + if path.exists() != expected_exists { + return Err(LegacyMigrationError::InvalidRequest(format!( + "target changed after planning: {}", + path.display() + ))); + } + if let Some(expected_hash) = expected_hash { + require_file_hash(path, expected_hash)?; + } + Ok(()) +} + +fn validate_session_relationship_closure( + manifest: &WorkspaceSessionsManifest, +) -> LegacyMigrationResult<()> { + let sessions = manifest + .sessions + .iter() + .map(|entry| (entry.session_id.as_str(), entry)) + .collect::>(); + let event_turns = manifest + .runtime_events + .iter() + .map(|entry| (entry.session_id.as_str(), &entry.turn_ids)) + .collect::>(); + for entry in &manifest.sessions { + let Some(relationship) = &entry.relationship else { + continue; + }; + let Some(parent_session_id) = relationship.parent_session_id.as_deref() else { + continue; + }; + let parent = sessions.get(parent_session_id).ok_or_else(|| { + LegacyMigrationError::UnsupportedSource(format!( + "Session {} references a missing parent Session", + entry.session_id + )) + })?; + if let Some(parent_turn_id) = relationship.parent_dialog_turn_id.as_deref() { + let in_persisted_turns = parent.turn_ids.contains(parent_turn_id); + let in_runtime_events = event_turns + .get(parent_session_id) + .is_some_and(|turns| turns.contains(parent_turn_id)); + if !in_persisted_turns && !in_runtime_events { + return Err(LegacyMigrationError::UnsupportedSource(format!( + "Session {} references a missing parent Turn", + entry.session_id + ))); + } + } + } + Ok(()) +} + +fn session_manifest_entry(session: &PlannedSession) -> SessionManifestEntry { + SessionManifestEntry { + runtime_relative: session + .runtime_relative + .to_string_lossy() + .replace('\\', "/"), + session_id: session.bundle.metadata.session_id.clone(), + action: session.action, + expected_hash: session.expected_hash.clone(), + turn_ids: session + .bundle + .turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect(), + relationship: session.bundle.metadata.relationship.clone(), + } +} + +fn imported_sessions( + manifest: &WorkspaceSessionsManifest, +) -> impl Iterator { + manifest + .sessions + .iter() + .filter(|entry| entry.action == SessionImportAction::Import) +} + +fn imported_runtime_events( + manifest: &WorkspaceSessionsManifest, +) -> impl Iterator { + manifest + .runtime_events + .iter() + .filter(|entry| entry.action == SessionImportAction::Import) +} + +pub(crate) fn read_workspace_sessions_manifest( + context: &DomainContext<'_>, +) -> LegacyMigrationResult { + read_bounded_json( + &context.layout.stage_root(), + &workspace_sessions_manifest_path(context), + ) +} + +fn workspace_sessions_manifest_path(context: &DomainContext<'_>) -> PathBuf { + stage_domain_dir(context, "workspace-sessions").join("manifest.json") +} + +fn source_workspace_data_path(roots: &MigrationRoots) -> PathBuf { + roots.legacy_user_root.join("data/workspace_data.json") +} + +fn target_workspace_data_path(roots: &MigrationRoots) -> PathBuf { + roots.target_user_root.join("data/workspace_data.json") +} + +fn path_from_manifest(value: &str) -> LegacyMigrationResult { + let path = PathBuf::from(value); + if path.as_os_str().is_empty() + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(LegacyMigrationError::PathEscape(path)); + } + Ok(path) +} + +fn file_name(path: &Path) -> LegacyMigrationResult { + path.file_name() + .and_then(|value| value.to_str()) + .map(str::to_string) + .ok_or_else(|| { + LegacyMigrationError::UnsupportedSource(format!( + "path component is not valid UTF-8: {}", + path.display() + )) + }) +} + +fn safe_component(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '_' + } + }) + .collect() +} + +fn offline_runtime() -> LegacyMigrationResult { + tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "failed to initialize offline Session writer: {error}" + )) + }) +} + +fn owner_error(context: &str, error: impl std::fmt::Display) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!("{context}: {error}")) +} + +fn json_error(error: serde_json::Error) -> LegacyMigrationError { + LegacyMigrationError::InvalidRequest(format!("JSON conversion failed: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_workspace_paths_are_normalized_with_posix_semantics() { + let mut workspace: WorkspaceInfo = serde_json::from_value(serde_json::json!({ + "id": "legacy-remote", + "name": "Remote fixture", + "rootPath": "\\srv\\repo\\", + "workspaceType": "Other", + "workspaceKind": "remote", + "status": "Inactive", + "languages": [], + "openedAt": "2026-01-01T00:00:00Z", + "lastAccessed": "2026-01-01T00:00:00Z", + "description": null, + "tags": [], + "statistics": null, + "relatedPaths": [], + "metadata": { + "sshHost": "fixture.example", + "connectionId": "fixture-connection" + } + })) + .unwrap(); + + normalize_legacy_workspace_for_current(&mut workspace).unwrap(); + assert_eq!(workspace.root_path.to_string_lossy(), "/srv/repo"); + assert!(current_workspace_storage_id(&workspace) + .unwrap() + .starts_with("remote_")); + } +} diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index ac70cf81ae..e4145462fa 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -30,6 +30,8 @@ pub mod function_agents; // Function-based agents pub mod infrastructure; // AI clients, storage, logging, events #[cfg(feature = "external-sources")] mod instruction_sources; +#[cfg(feature = "legacy-migration")] +pub mod legacy_migration; #[cfg(feature = "tools-miniapp")] pub mod miniapp; // AI-generated instant apps (Zero-Dialect Runtime) #[cfg(feature = "agent-runtime")] diff --git a/src/crates/assembly/core/src/service/coordination_persistence.rs b/src/crates/assembly/core/src/service/coordination_persistence.rs new file mode 100644 index 0000000000..c60b6931e4 --- /dev/null +++ b/src/crates/assembly/core/src/service/coordination_persistence.rs @@ -0,0 +1,177 @@ +//! Physical schema owner for the durable Agent coordination database. +//! +//! Runtime coordination and offline legacy import both open this database, but +//! neither should carry a private copy of its versioning and repair rules. + +use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; +use rusqlite::Connection; + +pub(crate) const COORDINATION_SCHEMA_VERSION: i64 = 2; + +pub(crate) fn initialize_coordination_schema(connection: &Connection) -> OpenBitFunResult<()> { + let version = connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) + .map_err(db_error)?; + if version > COORDINATION_SCHEMA_VERSION { + return Err(OpenBitFunError::service(format!( + "Agent coordination database schema {version} is newer than supported schema {COORDINATION_SCHEMA_VERSION}" + ))); + } + if version == 0 { + connection + .execute_batch( + r#" +CREATE TABLE coordination_sessions ( + parent_session_id TEXT PRIMARY KEY, + next_auto_agent_seq INTEGER NOT NULL DEFAULT 1, + updated_at_ms INTEGER NOT NULL +); + +CREATE TABLE agents ( + agent_pk INTEGER PRIMARY KEY AUTOINCREMENT, + parent_session_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + child_session_id TEXT, + next_bg_seq INTEGER NOT NULL DEFAULT 1, + state TEXT NOT NULL CHECK (state IN ('active', 'historical')), + created_at_ms INTEGER NOT NULL, + UNIQUE(parent_session_id, agent_id), + UNIQUE(parent_session_id, child_session_id) +); + +CREATE TABLE background_tasks ( + task_pk INTEGER PRIMARY KEY AUTOINCREMENT, + parent_session_id TEXT NOT NULL, + agent_pk INTEGER NOT NULL, + bg_task_id TEXT NOT NULL, + bg_ordinal INTEGER NOT NULL, + parent_dialog_turn_id TEXT NOT NULL, + parent_tool_call_id TEXT NOT NULL, + child_dialog_turn_id TEXT NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('running', 'completed', 'partial_timeout', 'failed', 'cancelled', 'interrupted') + ), + error_code TEXT, + error_message TEXT, + execution_owner_token TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + terminal_at_ms INTEGER, + delivered_at_ms INTEGER, + delivered_parent_dialog_turn_id TEXT, + UNIQUE(parent_session_id, bg_task_id), + UNIQUE(agent_pk, bg_ordinal), + FOREIGN KEY(agent_pk) REFERENCES agents(agent_pk) ON DELETE CASCADE +); + +CREATE INDEX idx_background_tasks_wait + ON background_tasks(parent_session_id, delivered_at_ms, status, task_pk); +CREATE INDEX idx_background_tasks_parent_turn + ON background_tasks(parent_session_id, parent_dialog_turn_id); + +PRAGMA user_version = 1; + "#, + ) + .map_err(db_error)?; + } + if version < 2 { + connection + .execute_batch( + r#" +CREATE TABLE swarm_trees ( + root_session_id TEXT PRIMARY KEY, + created_at_ms INTEGER NOT NULL +); + +CREATE TABLE swarm_nodes ( + session_id TEXT PRIMARY KEY, + root_session_id TEXT NOT NULL, + parent_session_id TEXT, + agent_type TEXT NOT NULL, + depth INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL, + FOREIGN KEY(root_session_id) REFERENCES swarm_trees(root_session_id) ON DELETE CASCADE +); + +CREATE INDEX idx_swarm_nodes_root ON swarm_nodes(root_session_id); +CREATE INDEX idx_swarm_nodes_parent ON swarm_nodes(parent_session_id); +PRAGMA user_version = 2; + "#, + ) + .map_err(db_error)?; + } + ensure_v2_additive_columns(connection)?; + Ok(()) +} + +/// Schema v2 shipped after the background-task delivery columns were added to +/// schema v1. A retired build could nevertheless leave a database stamped as +/// v2 without those additive columns. Keep the repair keyed to the physical +/// shape so reopening such a database is safe and idempotent. +fn ensure_v2_additive_columns(connection: &Connection) -> OpenBitFunResult<()> { + for (column, declaration) in [ + ("delivered_at_ms", "INTEGER"), + ("delivered_parent_dialog_turn_id", "TEXT"), + ] { + if !coordination_table_has_column(connection, "background_tasks", column)? { + connection + .execute_batch(&format!( + "ALTER TABLE background_tasks ADD COLUMN {column} {declaration};" + )) + .map_err(db_error)?; + } + } + connection + .execute_batch( + r#" +CREATE INDEX IF NOT EXISTS idx_background_tasks_wait + ON background_tasks(parent_session_id, delivered_at_ms, status, task_pk); +CREATE INDEX IF NOT EXISTS idx_background_tasks_parent_turn + ON background_tasks(parent_session_id, parent_dialog_turn_id); + "#, + ) + .map_err(db_error)?; + Ok(()) +} + +pub(crate) fn coordination_table_has_column( + connection: &Connection, + table: &str, + expected_column: &str, +) -> OpenBitFunResult { + let mut statement = connection + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(db_error)?; + let columns = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(db_error)?; + for column in columns { + if column.map_err(db_error)? == expected_column { + return Ok(true); + } + } + Ok(false) +} + +pub(crate) fn validate_coordination_agent_id(agent_id: &str) -> OpenBitFunResult<()> { + let valid = !agent_id.is_empty() + && agent_id.len() <= 32 + && agent_id + .bytes() + .enumerate() + .all(|(index, byte)| match byte { + b'a'..=b'z' => true, + b'0'..=b'9' | b'_' | b'-' => index > 0, + _ => false, + }); + if valid { + Ok(()) + } else { + Err(OpenBitFunError::tool( + "agent_id must match [a-z][a-z0-9_-]{0,31}".to_string(), + )) + } +} + +fn db_error(error: rusqlite::Error) -> OpenBitFunError { + OpenBitFunError::io(format!("Agent coordination database error: {error}")) +} diff --git a/src/crates/assembly/core/src/service/mod.rs b/src/crates/assembly/core/src/service/mod.rs index 812fd9f6a4..0470e535c3 100644 --- a/src/crates/assembly/core/src/service/mod.rs +++ b/src/crates/assembly/core/src/service/mod.rs @@ -11,6 +11,8 @@ pub(crate) mod bootstrap; // Workspace persona bootstrap helpers #[cfg(feature = "canvas-runtime")] pub mod canvas; // Canvas service compatibility facade pub mod config; // Config management +#[cfg(any(feature = "agent-runtime", feature = "legacy-migration"))] +pub(crate) mod coordination_persistence; #[cfg(all(feature = "agent-runtime", feature = "scheduled-jobs"))] pub mod cron; // Scheduled jobs #[cfg(feature = "dispatch-store")] @@ -39,6 +41,8 @@ pub mod runtime; // Managed runtime and capability management pub mod search; // Workspace search via managed flashgrep daemon #[cfg(feature = "local-storage")] pub mod session; // Session persistence +#[cfg(any(feature = "agent-runtime", feature = "legacy-migration"))] +pub(crate) mod session_projection_format; #[cfg(feature = "agent-runtime")] pub mod session_projection_store; // Durable append-only log of the executing Turn #[cfg(feature = "agent-runtime")] @@ -51,6 +55,9 @@ pub mod token_usage; // Token usage tracking pub mod web_search; // Provider-neutral WebSearch runtime and local credentials #[cfg(feature = "workspace-runtime")] pub mod workspace; // Workspace management // Diff calculation and merge service +#[cfg(all(feature = "legacy-migration", not(feature = "workspace-runtime")))] +#[path = "workspace/mod.rs"] +pub(crate) mod workspace; #[cfg(feature = "workspace-runtime")] pub mod workspace_runtime; // Workspace runtime layout / migration / initialization #[cfg(all(feature = "agent-runtime", feature = "git"))] diff --git a/src/crates/assembly/core/src/service/session_projection_format.rs b/src/crates/assembly/core/src/service/session_projection_format.rs new file mode 100644 index 0000000000..dddfd7579a --- /dev/null +++ b/src/crates/assembly/core/src/service/session_projection_format.rs @@ -0,0 +1,121 @@ +//! Stable on-disk envelope for in-flight Session runtime events. + +use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; +use openbitfun_events::AgenticEvent; +use std::collections::BTreeSet; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LoggedEvent { + pub(crate) stream_id: String, + pub(crate) cursor: u64, + pub(crate) event: AgenticEvent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuntimeEventLogSummary { + pub(crate) stream_id: String, + pub(crate) event_count: usize, + pub(crate) turn_ids: BTreeSet, +} + +/// Validate an imported JSONL file through the same envelope and durable-prefix +/// semantics used by the runtime store. Migration keeps the original bytes and +/// ordering; a torn tail is retained but is not considered part of the readable +/// event prefix. +pub(crate) fn validate_runtime_event_log( + path: &Path, + expected_session_id: &str, +) -> OpenBitFunResult { + let file = std::fs::File::open(path).map_err(|error| { + OpenBitFunError::io(format!( + "Failed to open runtime event log {}: {error}", + path.display() + )) + })?; + let mut stream_id: Option = None; + let mut event_count = 0usize; + let mut turn_ids = BTreeSet::new(); + for (line_index, line) in BufReader::new(file).lines().enumerate() { + let line = line.map_err(|error| { + OpenBitFunError::io(format!( + "Failed to read runtime event log {} at line {}: {error}", + path.display(), + line_index + 1 + )) + })?; + if line.trim().is_empty() { + continue; + } + let Ok(record) = serde_json::from_str::(&line) else { + break; + }; + if let Some(session_id) = record.event.session_id() { + if session_id != expected_session_id { + return Err(OpenBitFunError::validation(format!( + "Runtime event log {} contains a different Session id", + path.display() + ))); + } + } + if let Some(turn_id) = record.event.turn_id() { + turn_ids.insert(turn_id.to_string()); + } + match stream_id.as_deref() { + Some(current) if current != record.stream_id => { + turn_ids.clear(); + event_count = 0; + stream_id = Some(record.stream_id); + } + None => stream_id = Some(record.stream_id), + _ => {} + } + event_count = event_count.saturating_add(1); + } + let stream_id = stream_id.ok_or_else(|| { + OpenBitFunError::validation(format!( + "Runtime event log {} contains no events", + path.display() + )) + })?; + Ok(RuntimeEventLogSummary { + stream_id, + event_count, + turn_ids, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + #[test] + fn imported_log_keeps_the_runtime_readers_torn_tail_semantics() { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("E:/tmp")); + fs::create_dir_all(&root).unwrap(); + let temp = tempfile::Builder::new() + .prefix("openbitfun-runtime-event-") + .tempdir_in(root) + .unwrap(); + let path = temp.path().join("session-1.jsonl"); + fs::write( + &path, + concat!( + "{\"streamId\":\"stream-1\",\"cursor\":1,\"event\":{\"type\":\"TextChunk\",\"session_id\":\"session-1\",\"turn_id\":\"turn-1\",\"round_id\":\"round-1\",\"text\":\"durable\"}}\n", + "{\"streamId\":\"stream-1\",\"cursor\":" + ), + ) + .unwrap(); + + let summary = validate_runtime_event_log(&path, "session-1").unwrap(); + assert_eq!(summary.stream_id, "stream-1"); + assert_eq!(summary.event_count, 1); + assert_eq!(summary.turn_ids, BTreeSet::from(["turn-1".to_string()])); + } +} 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..3b7e2fbc71 100644 --- a/src/crates/assembly/core/src/service/session_projection_store.rs +++ b/src/crates/assembly/core/src/service/session_projection_store.rs @@ -10,6 +10,7 @@ //! Turn reaches a terminal state the Session record owns it and this log is //! dropped, so the two never describe the same thing at the same time. +use super::session_projection_format::LoggedEvent; use openbitfun_agent_runtime::sdk::{SessionEventProjectionStore, StoredSessionEvents}; use openbitfun_events::AgenticEvent; use std::collections::HashMap; @@ -18,16 +19,6 @@ use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -/// 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)] -#[serde(rename_all = "camelCase")] -struct LoggedEvent { - stream_id: String, - cursor: u64, - event: AgenticEvent, -} - /// 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. diff --git a/src/crates/assembly/core/src/service/workspace/manager.rs b/src/crates/assembly/core/src/service/workspace/manager.rs index cc2aa04b20..891bd1013e 100644 --- a/src/crates/assembly/core/src/service/workspace/manager.rs +++ b/src/crates/assembly/core/src/service/workspace/manager.rs @@ -1,10 +1,15 @@ //! Workspace manager. +pub use super::types::{ + GitInfo, PrimaryAssistantKey, WorkspaceIdentity, WorkspaceInfo, WorkspaceKind, + WorkspaceStatistics, WorkspaceStatus, WorkspaceType, WorkspaceWorktreeInfo, +}; #[cfg(feature = "git")] use super::worktree_topology::global_worktree_topology_service; use super::WorktreeTopologyFreshness; use crate::util::{errors::*, FrontMatterMarkdown}; use log::warn; +pub use openbitfun_runtime_ports::RelatedPath; use openbitfun_services_core::workspace_identity::{ canonicalize_local_workspace_root, local_workspace_stable_storage_id, normalize_local_workspace_root_for_stable_id, normalize_remote_workspace_path, @@ -16,108 +21,8 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use tokio::fs; -pub use openbitfun_runtime_ports::RelatedPath; - -/// Workspace type. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub enum WorkspaceType { - RustProject, - NodeProject, - PythonProject, - JavaProject, - CppProject, - WebProject, - MobileProject, - Other, -} - -/// Workspace status. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum WorkspaceStatus { - Active, - Inactive, - Loading, - Error, - Archived, -} - -/// Workspace lifecycle kind. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "lowercase")] -pub enum WorkspaceKind { - #[default] - Normal, - Assistant, - Remote, -} - -/// Stable identity of the assistant workspace that owns the primary role. -/// -/// Local workspace ids are derived from canonical storage paths, so the primary -/// selection is persisted using the assistant identity instead of that path-derived id. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum PrimaryAssistantKey { - BuiltIn, - Named { assistant_id: String }, -} - -impl PrimaryAssistantKey { - pub fn from_workspace(workspace: &WorkspaceInfo) -> Option { - if workspace.workspace_kind != WorkspaceKind::Assistant { - return None; - } - - Some(match workspace.assistant_id.as_deref() { - Some(assistant_id) if !assistant_id.trim().is_empty() => Self::Named { - assistant_id: assistant_id.trim().to_string(), - }, - _ => Self::BuiltIn, - }) - } - - pub fn matches(&self, workspace: &WorkspaceInfo) -> bool { - if workspace.workspace_kind != WorkspaceKind::Assistant { - return false; - } - - match (self, workspace.assistant_id.as_deref()) { - (Self::BuiltIn, None) => true, - (Self::Named { assistant_id }, Some(candidate)) => assistant_id == candidate, - _ => false, - } - } -} - pub(crate) const IDENTITY_FILE_NAME: &str = "IDENTITY.md"; -/// Parsed agent identity fields from `IDENTITY.md` frontmatter. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WorkspaceIdentity { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub creature: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub vibe: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub avatar: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub emoji: Option, -} - -/// Git worktree metadata attached to a workspace. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct WorkspaceWorktreeInfo { - pub path: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub branch: Option, - pub main_repo_path: String, - pub is_main: bool, -} - #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] struct WorkspaceIdentityFrontmatter { @@ -175,6 +80,7 @@ impl WorkspaceIdentity { && self.emoji.is_none() } + #[cfg(any(feature = "agent-runtime", test))] pub(crate) fn collect_changed_fields( previous: Option<&WorkspaceIdentity>, current: Option<&WorkspaceIdentity>, @@ -222,62 +128,6 @@ fn normalize_identity_field(value: Option) -> Option { }) } -/// Workspace metadata. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceInfo { - pub id: String, - pub name: String, - #[serde(rename = "rootPath")] - pub root_path: PathBuf, - #[serde(rename = "workspaceType")] - pub workspace_type: WorkspaceType, - #[serde(rename = "workspaceKind", default)] - pub workspace_kind: WorkspaceKind, - #[serde( - rename = "assistantId", - default, - skip_serializing_if = "Option::is_none" - )] - pub assistant_id: Option, - pub status: WorkspaceStatus, - pub languages: Vec, - #[serde(rename = "openedAt")] - pub opened_at: chrono::DateTime, - #[serde(rename = "lastAccessed")] - pub last_accessed: chrono::DateTime, - pub description: Option, - pub tags: Vec, - pub statistics: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub identity: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub worktree: Option, - #[serde(rename = "relatedPaths", default)] - pub related_paths: Vec, - pub metadata: HashMap, -} - -/// Workspace statistics. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceStatistics { - pub total_files: usize, - pub total_directories: usize, - pub total_size_bytes: u64, - pub file_extensions: HashMap, - pub last_modified: Option>, - pub git_info: Option, -} - -/// Git information. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GitInfo { - pub is_git_repo: bool, - pub current_branch: Option, - pub remote_url: Option, - pub has_uncommitted_changes: bool, - pub total_commits: Option, -} - /// Options for scanning a workspace. #[derive(Debug, Clone)] pub struct ScanOptions { @@ -343,14 +193,6 @@ impl Default for WorkspaceOpenOptions { } impl WorkspaceInfo { - /// SSH connection id persisted in [`WorkspaceInfo::metadata`] for remote workspaces. - pub fn remote_ssh_connection_id(&self) -> Option<&str> { - self.metadata - .get("connectionId") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - } - /// Creates a new workspace record. pub async fn new(root_path: PathBuf, options: WorkspaceOpenOptions) -> OpenBitFunResult { Self::new_inner(root_path, options, true).await diff --git a/src/crates/assembly/core/src/service/workspace/mod.rs b/src/crates/assembly/core/src/service/workspace/mod.rs index 375cbd7238..c4d29adb60 100644 --- a/src/crates/assembly/core/src/service/workspace/mod.rs +++ b/src/crates/assembly/core/src/service/workspace/mod.rs @@ -2,37 +2,50 @@ //! //! Full workspace management system: open, manage, scan, statistics, etc. +#[cfg(feature = "workspace-runtime")] pub mod factory; #[cfg(feature = "workspace-watch")] pub mod identity_watch; +#[cfg(feature = "workspace-runtime")] pub mod manager; +pub(crate) mod persistence; +#[cfg(feature = "workspace-runtime")] pub mod provider; +#[cfg(feature = "workspace-runtime")] pub mod service; +pub(crate) mod types; #[cfg(feature = "git")] pub mod worktree_topology; #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg(feature = "workspace-runtime")] pub enum WorktreeTopologyFreshness { Cached, ForceRefresh, } // Re-export main components +#[cfg(feature = "workspace-runtime")] pub use factory::WorkspaceFactory; #[cfg(feature = "workspace-watch")] pub use identity_watch::WorkspaceIdentityWatchService; +#[cfg(feature = "workspace-runtime")] pub use manager::{ GitInfo, PrimaryAssistantKey, RelatedPath, ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceKind, WorkspaceManager, WorkspaceManagerConfig, WorkspaceManagerStatistics, WorkspaceOpenOptions, WorkspaceStatistics, WorkspaceStatus, WorkspaceSummary, WorkspaceType, WorkspaceWorktreeInfo, }; +#[cfg(feature = "workspace-runtime")] pub use provider::{WorkspaceCleanupResult, WorkspaceProvider, WorkspaceSystemSummary}; +#[cfg(feature = "workspace-runtime")] pub use service::{ get_global_workspace_service, set_global_workspace_service, BatchImportResult, BatchRemoveResult, WorkspaceActivityMode, WorkspaceCreateOptions, WorkspaceExport, WorkspaceHealthStatus, WorkspaceIdentityChangedEvent, WorkspaceImportResult, WorkspaceInfoUpdates, WorkspaceQuickSummary, WorkspaceService, }; +#[cfg(all(feature = "legacy-migration", not(feature = "workspace-runtime")))] +pub(crate) use types::{PrimaryAssistantKey, WorkspaceInfo, WorkspaceKind}; #[cfg(feature = "git")] pub use worktree_topology::{global_worktree_topology_service, WorktreeTopologyService}; diff --git a/src/crates/assembly/core/src/service/workspace/persistence.rs b/src/crates/assembly/core/src/service/workspace/persistence.rs new file mode 100644 index 0000000000..a8ffc22517 --- /dev/null +++ b/src/crates/assembly/core/src/service/workspace/persistence.rs @@ -0,0 +1,230 @@ +//! Current Workspace registry persistence contract and validation. + +use super::types::{PrimaryAssistantKey, WorkspaceInfo, WorkspaceKind}; +use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; +use openbitfun_core_types::product_identity::product_id; +use openbitfun_services_core::workspace_identity::{ + canonicalize_local_workspace_root, local_workspace_stable_storage_id, + normalize_remote_workspace_path, remote_workspace_stable_id, LOCAL_WORKSPACE_SSH_HOST, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +pub(crate) const WORKSPACE_PERSISTENCE_FORMAT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct WorkspacePersistenceData { + #[serde(default)] + pub(crate) format_version: u32, + #[serde(default)] + pub(crate) product_id: String, + pub(crate) workspaces: HashMap, + #[serde(default)] + pub(crate) opened_workspace_ids: Vec, + pub(crate) current_workspace_id: Option, + #[serde(default)] + pub(crate) recent_workspaces: Vec, + #[serde(default)] + pub(crate) recent_assistant_workspaces: Vec, + #[serde(default)] + pub(crate) primary_assistant_key: Option, + pub(crate) saved_at: chrono::DateTime, +} + +pub(crate) fn current_workspace_storage_id(workspace: &WorkspaceInfo) -> OpenBitFunResult { + match workspace.workspace_kind { + WorkspaceKind::Remote => { + let ssh_host = workspace + .metadata + .get("sshHost") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + unsupported_workspace_persistence(format!( + "remote workspace '{}' is missing sshHost", + workspace.id + )) + })?; + workspace.remote_ssh_connection_id().ok_or_else(|| { + unsupported_workspace_persistence(format!( + "remote workspace '{}' is missing connectionId", + workspace.id + )) + })?; + + let stored_root = workspace.root_path.to_string_lossy().replace('\\', "/"); + let normalized_root = normalize_remote_workspace_path(&stored_root); + if !normalized_root.starts_with('/') { + return Err(unsupported_workspace_persistence(format!( + "remote workspace '{}' does not use an absolute POSIX root", + workspace.id + ))); + } + if stored_root != normalized_root { + return Err(unsupported_workspace_persistence(format!( + "remote workspace '{}' rootPath is not normalized", + workspace.id + ))); + } + Ok(remote_workspace_stable_id(ssh_host, &normalized_root)) + } + WorkspaceKind::Normal | WorkspaceKind::Assistant => { + let ssh_host = workspace + .metadata + .get("sshHost") + .and_then(|value| value.as_str()) + .map(str::trim); + if ssh_host != Some(LOCAL_WORKSPACE_SSH_HOST) { + return Err(unsupported_workspace_persistence(format!( + "local workspace '{}' does not declare sshHost=localhost", + workspace.id + ))); + } + expected_persisted_local_workspace_id(&workspace.root_path).map_err(|error| { + unsupported_workspace_persistence(format!( + "local workspace '{}' is not canonical: {error}", + workspace.id + )) + }) + } + } +} + +pub(crate) fn validate_workspace_persistence_data( + data: &WorkspacePersistenceData, + miniapps_root: &Path, +) -> OpenBitFunResult<()> { + if data.format_version != WORKSPACE_PERSISTENCE_FORMAT_VERSION { + return Err(unsupported_workspace_persistence(format!( + "format_version {} is not supported; expected {}", + data.format_version, WORKSPACE_PERSISTENCE_FORMAT_VERSION + ))); + } + if data.product_id != product_id() { + return Err(unsupported_workspace_persistence(format!( + "product_id '{}' does not match '{}'", + data.product_id, + product_id() + ))); + } + + for (storage_id, workspace) in &data.workspaces { + if storage_id != &workspace.id { + return Err(unsupported_workspace_persistence(format!( + "workspace map key '{storage_id}' does not match record id '{}'", + workspace.id + ))); + } + let expected_id = current_workspace_storage_id(workspace)?; + if storage_id != &expected_id { + return Err(unsupported_workspace_persistence(format!( + "workspace id '{storage_id}' is not canonical; expected '{expected_id}'" + ))); + } + } + validate_workspace_reference_list( + &data.workspaces, + &data.opened_workspace_ids, + "opened_workspace_ids", + )?; + validate_workspace_reference_list( + &data.workspaces, + &data.recent_workspaces, + "recent_workspaces", + )?; + validate_workspace_reference_list( + &data.workspaces, + &data.recent_assistant_workspaces, + "recent_assistant_workspaces", + )?; + + for id in &data.recent_workspaces { + let workspace = &data.workspaces[id]; + if workspace.workspace_kind == WorkspaceKind::Assistant { + return Err(unsupported_workspace_persistence(format!( + "recent_workspaces contains assistant workspace '{id}'" + ))); + } + if workspace.root_path.starts_with(miniapps_root) { + return Err(unsupported_workspace_persistence(format!( + "recent_workspaces contains MiniApp-owned workspace '{id}'" + ))); + } + } + for id in &data.recent_assistant_workspaces { + if data.workspaces[id].workspace_kind != WorkspaceKind::Assistant { + return Err(unsupported_workspace_persistence(format!( + "recent_assistant_workspaces contains non-assistant workspace '{id}'" + ))); + } + } + + if let Some(current_id) = data.current_workspace_id.as_deref() { + if !data.workspaces.contains_key(current_id) { + return Err(unsupported_workspace_persistence(format!( + "current_workspace_id references unknown workspace id '{current_id}'" + ))); + } + if !data.opened_workspace_ids.iter().any(|id| id == current_id) { + return Err(unsupported_workspace_persistence(format!( + "current workspace '{current_id}' is not present in opened_workspace_ids" + ))); + } + } + + Ok(()) +} + +fn expected_persisted_local_workspace_id(root_path: &Path) -> Result { + if !root_path.is_absolute() { + return Err(format!( + "local workspace rootPath is not absolute: {}", + root_path.display() + )); + } + + let normalized_root = if root_path.exists() { + let (canonical_root, normalized_root) = canonicalize_local_workspace_root(root_path)?; + if canonical_root != root_path { + return Err(format!( + "local workspace rootPath is not canonical: {}", + root_path.display() + )); + } + normalized_root + } else { + root_path.to_string_lossy().replace('\\', "/") + }; + + Ok(local_workspace_stable_storage_id(&normalized_root)) +} + +fn validate_workspace_reference_list( + workspaces: &HashMap, + ids: &[String], + field: &str, +) -> OpenBitFunResult<()> { + let mut seen = HashSet::new(); + for id in ids { + if !seen.insert(id.as_str()) { + return Err(unsupported_workspace_persistence(format!( + "{field} contains duplicate workspace id '{id}'" + ))); + } + if !workspaces.contains_key(id) { + return Err(unsupported_workspace_persistence(format!( + "{field} references unknown workspace id '{id}'" + ))); + } + } + Ok(()) +} + +pub(crate) fn unsupported_workspace_persistence(detail: impl AsRef) -> OpenBitFunError { + OpenBitFunError::config(format!( + "Unsupported workspace persistence format: {}. The persisted file was left unchanged; explicit data migration is required", + detail.as_ref() + )) +} diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index ca78026db6..6620b1d9b8 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -7,6 +7,10 @@ use super::manager::{ WorkspaceManager, WorkspaceManagerConfig, WorkspaceManagerStatistics, WorkspaceOpenOptions, WorkspaceStatus, WorkspaceSummary, WorkspaceType, }; +use super::persistence::{ + unsupported_workspace_persistence, validate_workspace_persistence_data, + WorkspacePersistenceData, WORKSPACE_PERSISTENCE_FORMAT_VERSION, +}; use super::WorktreeTopologyFreshness; use crate::infrastructure::storage::{PersistenceService, StorageOptions}; use crate::infrastructure::{try_get_path_manager_arc, PathManager}; @@ -27,8 +31,7 @@ use log::{info, warn}; use openbitfun_core_types::product_identity::product_id; use openbitfun_services_core::workspace_identity::{ canonicalize_local_workspace_root, local_workspace_roots_equal, - local_workspace_stable_storage_id, normalize_remote_workspace_path, remote_workspace_stable_id, - LOCAL_WORKSPACE_SSH_HOST, + normalize_remote_workspace_path, remote_workspace_stable_id, }; use serde::{Deserialize, Serialize}; @@ -39,7 +42,6 @@ use tokio::fs; use tokio::sync::RwLock; const MAX_WORKSPACE_NAME_CHARS: usize = 80; -const WORKSPACE_PERSISTENCE_FORMAT_VERSION: u32 = 1; /// Workspace service. pub struct WorkspaceService { @@ -143,210 +145,6 @@ impl WorkspaceService { Ok(name.to_string()) } - fn unsupported_workspace_persistence(detail: impl AsRef) -> OpenBitFunError { - OpenBitFunError::config(format!( - "Unsupported workspace persistence format: {}. The persisted file was left unchanged; explicit data migration is required", - detail.as_ref() - )) - } - - fn expected_persisted_local_workspace_id(root_path: &Path) -> Result { - if !root_path.is_absolute() { - return Err(format!( - "local workspace rootPath is not absolute: {}", - root_path.display() - )); - } - - let normalized_root = if root_path.exists() { - let (canonical_root, normalized_root) = canonicalize_local_workspace_root(root_path)?; - if canonical_root != root_path { - return Err(format!( - "local workspace rootPath is not canonical: {}", - root_path.display() - )); - } - normalized_root - } else { - // A missing local root is still valid history. New OpenBitFun records persist the - // canonical absolute path, so its stored spelling remains the stable-id input even - // when the directory is temporarily unavailable. - root_path.to_string_lossy().replace('\\', "/") - }; - - Ok(local_workspace_stable_storage_id(&normalized_root)) - } - - fn validate_persisted_workspace_record( - &self, - storage_id: &str, - workspace: &WorkspaceInfo, - ) -> OpenBitFunResult<()> { - if storage_id != workspace.id { - return Err(Self::unsupported_workspace_persistence(format!( - "workspace map key '{storage_id}' does not match record id '{}'", - workspace.id - ))); - } - - let expected_id = match workspace.workspace_kind { - WorkspaceKind::Remote => { - let ssh_host = workspace - .metadata - .get("sshHost") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - Self::unsupported_workspace_persistence(format!( - "remote workspace '{storage_id}' is missing sshHost" - )) - })?; - workspace.remote_ssh_connection_id().ok_or_else(|| { - Self::unsupported_workspace_persistence(format!( - "remote workspace '{storage_id}' is missing connectionId" - )) - })?; - - let stored_root = workspace.root_path.to_string_lossy().replace('\\', "/"); - let normalized_root = normalize_remote_workspace_path(&stored_root); - if !normalized_root.starts_with('/') { - return Err(Self::unsupported_workspace_persistence(format!( - "remote workspace '{storage_id}' does not use an absolute POSIX root" - ))); - } - if stored_root != normalized_root { - return Err(Self::unsupported_workspace_persistence(format!( - "remote workspace '{storage_id}' rootPath is not normalized" - ))); - } - remote_workspace_stable_id(ssh_host, &normalized_root) - } - WorkspaceKind::Normal | WorkspaceKind::Assistant => { - let ssh_host = workspace - .metadata - .get("sshHost") - .and_then(|value| value.as_str()) - .map(str::trim); - if ssh_host != Some(LOCAL_WORKSPACE_SSH_HOST) { - return Err(Self::unsupported_workspace_persistence(format!( - "local workspace '{storage_id}' does not declare sshHost=localhost" - ))); - } - Self::expected_persisted_local_workspace_id(&workspace.root_path).map_err( - |error| { - Self::unsupported_workspace_persistence(format!( - "local workspace '{storage_id}' is not canonical: {error}" - )) - }, - )? - } - }; - - if storage_id != expected_id { - return Err(Self::unsupported_workspace_persistence(format!( - "workspace id '{storage_id}' is not canonical; expected '{expected_id}'" - ))); - } - - Ok(()) - } - - fn validate_workspace_reference_list( - workspaces: &std::collections::HashMap, - ids: &[String], - field: &str, - ) -> OpenBitFunResult<()> { - let mut seen = HashSet::new(); - for id in ids { - if !seen.insert(id.as_str()) { - return Err(Self::unsupported_workspace_persistence(format!( - "{field} contains duplicate workspace id '{id}'" - ))); - } - if !workspaces.contains_key(id) { - return Err(Self::unsupported_workspace_persistence(format!( - "{field} references unknown workspace id '{id}'" - ))); - } - } - Ok(()) - } - - fn validate_workspace_persistence_data( - &self, - data: &WorkspacePersistenceData, - ) -> OpenBitFunResult<()> { - if data.format_version != WORKSPACE_PERSISTENCE_FORMAT_VERSION { - return Err(Self::unsupported_workspace_persistence(format!( - "format_version {} is not supported; expected {}", - data.format_version, WORKSPACE_PERSISTENCE_FORMAT_VERSION - ))); - } - if data.product_id != product_id() { - return Err(Self::unsupported_workspace_persistence(format!( - "product_id '{}' does not match '{}'", - data.product_id, - product_id() - ))); - } - - for (storage_id, workspace) in &data.workspaces { - self.validate_persisted_workspace_record(storage_id, workspace)?; - } - Self::validate_workspace_reference_list( - &data.workspaces, - &data.opened_workspace_ids, - "opened_workspace_ids", - )?; - Self::validate_workspace_reference_list( - &data.workspaces, - &data.recent_workspaces, - "recent_workspaces", - )?; - Self::validate_workspace_reference_list( - &data.workspaces, - &data.recent_assistant_workspaces, - "recent_assistant_workspaces", - )?; - - for id in &data.recent_workspaces { - let workspace = &data.workspaces[id]; - if workspace.workspace_kind == WorkspaceKind::Assistant { - return Err(Self::unsupported_workspace_persistence(format!( - "recent_workspaces contains assistant workspace '{id}'" - ))); - } - if self.is_miniapp_owned_path(&workspace.root_path) { - return Err(Self::unsupported_workspace_persistence(format!( - "recent_workspaces contains MiniApp-owned workspace '{id}'" - ))); - } - } - for id in &data.recent_assistant_workspaces { - if data.workspaces[id].workspace_kind != WorkspaceKind::Assistant { - return Err(Self::unsupported_workspace_persistence(format!( - "recent_assistant_workspaces contains non-assistant workspace '{id}'" - ))); - } - } - - if let Some(current_id) = data.current_workspace_id.as_deref() { - if !data.workspaces.contains_key(current_id) { - return Err(Self::unsupported_workspace_persistence(format!( - "current_workspace_id references unknown workspace id '{current_id}'" - ))); - } - if !data.opened_workspace_ids.iter().any(|id| id == current_id) { - return Err(Self::unsupported_workspace_persistence(format!( - "current workspace '{current_id}' is not present in opened_workspace_ids" - ))); - } - } - - Ok(()) - } - fn collect_startup_restored_workspaces(manager: &WorkspaceManager) -> Vec { let mut targets = Vec::new(); let mut seen_workspace_ids = HashSet::new(); @@ -1972,7 +1770,7 @@ impl WorkspaceService { })?; if let Some(data) = workspace_data { - self.validate_workspace_persistence_data(&data)?; + validate_workspace_persistence_data(&data, &self.path_manager.miniapps_dir())?; let mut manager = self.manager.write().await; *manager.get_workspaces_mut() = data.workspaces; @@ -1983,7 +1781,7 @@ impl WorkspaceService { if let Some(current_id) = data.current_workspace_id { if let Err(e) = manager.set_current_workspace(current_id) { - return Err(Self::unsupported_workspace_persistence(format!( + return Err(unsupported_workspace_persistence(format!( "current workspace could not be restored: {e}" ))); } @@ -2383,26 +2181,6 @@ pub struct WorkspaceQuickSummary { pub workspace_types: std::collections::HashMap, } -/// Workspace persistence data. -#[derive(Debug, Serialize, Deserialize)] -struct WorkspacePersistenceData { - #[serde(default)] - pub format_version: u32, - #[serde(default)] - pub product_id: String, - pub workspaces: std::collections::HashMap, - #[serde(default)] - pub opened_workspace_ids: Vec, - pub current_workspace_id: Option, - #[serde(default)] - pub recent_workspaces: Vec, - #[serde(default)] - pub recent_assistant_workspaces: Vec, - #[serde(default)] - pub primary_assistant_key: Option, - pub saved_at: chrono::DateTime, -} - // ── Global workspace service singleton ────────────────────────────── static GLOBAL_WORKSPACE_SERVICE: std::sync::OnceLock> = diff --git a/src/crates/assembly/core/src/service/workspace/types.rs b/src/crates/assembly/core/src/service/workspace/types.rs new file mode 100644 index 0000000000..39de53492a --- /dev/null +++ b/src/crates/assembly/core/src/service/workspace/types.rs @@ -0,0 +1,175 @@ +//! Persisted Workspace record types shared by the live service and offline import. + +#![cfg_attr( + all(feature = "legacy-migration", not(feature = "workspace-runtime")), + allow(unreachable_pub) +)] + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +pub use openbitfun_runtime_ports::RelatedPath; + +/// Workspace type. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum WorkspaceType { + RustProject, + NodeProject, + PythonProject, + JavaProject, + CppProject, + WebProject, + MobileProject, + Other, +} + +/// Workspace status. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum WorkspaceStatus { + Active, + Inactive, + Loading, + Error, + Archived, +} + +/// Workspace lifecycle kind. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum WorkspaceKind { + #[default] + Normal, + Assistant, + Remote, +} + +/// Stable identity of the assistant workspace that owns the primary role. +/// +/// Local workspace ids are derived from canonical storage paths, so the primary +/// selection is persisted using the assistant identity instead of that path-derived id. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PrimaryAssistantKey { + BuiltIn, + Named { assistant_id: String }, +} + +#[cfg(feature = "workspace-runtime")] +impl PrimaryAssistantKey { + pub fn from_workspace(workspace: &WorkspaceInfo) -> Option { + if workspace.workspace_kind != WorkspaceKind::Assistant { + return None; + } + Some(match workspace.assistant_id.as_deref() { + Some(assistant_id) if !assistant_id.trim().is_empty() => Self::Named { + assistant_id: assistant_id.trim().to_string(), + }, + _ => Self::BuiltIn, + }) + } + + pub fn matches(&self, workspace: &WorkspaceInfo) -> bool { + if workspace.workspace_kind != WorkspaceKind::Assistant { + return false; + } + match (self, workspace.assistant_id.as_deref()) { + (Self::BuiltIn, None) => true, + (Self::Named { assistant_id }, Some(candidate)) => assistant_id == candidate, + _ => false, + } + } +} + +/// Parsed agent identity fields from `IDENTITY.md` frontmatter. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceIdentity { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vibe: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub emoji: Option, +} + +/// Git worktree metadata attached to a workspace. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceWorktreeInfo { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, + pub main_repo_path: String, + pub is_main: bool, +} + +/// Workspace metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceInfo { + pub id: String, + pub name: String, + #[serde(rename = "rootPath")] + pub root_path: PathBuf, + #[serde(rename = "workspaceType")] + pub workspace_type: WorkspaceType, + #[serde(rename = "workspaceKind", default)] + pub workspace_kind: WorkspaceKind, + #[serde( + rename = "assistantId", + default, + skip_serializing_if = "Option::is_none" + )] + pub assistant_id: Option, + pub status: WorkspaceStatus, + pub languages: Vec, + #[serde(rename = "openedAt")] + pub opened_at: chrono::DateTime, + #[serde(rename = "lastAccessed")] + pub last_accessed: chrono::DateTime, + pub description: Option, + pub tags: Vec, + pub statistics: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option, + #[serde(rename = "relatedPaths", default)] + pub related_paths: Vec, + pub metadata: HashMap, +} + +impl WorkspaceInfo { + /// SSH connection id persisted in [`WorkspaceInfo::metadata`] for remote workspaces. + pub fn remote_ssh_connection_id(&self) -> Option<&str> { + self.metadata + .get("connectionId") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + } +} + +/// Workspace statistics. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceStatistics { + pub total_files: usize, + pub total_directories: usize, + pub total_size_bytes: u64, + pub file_extensions: HashMap, + pub last_modified: Option>, + pub git_info: Option, +} + +/// Git information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitInfo { + pub is_git_repo: bool, + pub current_branch: Option, + pub remote_url: Option, + pub has_uncommitted_changes: bool, + pub total_commits: Option, +} diff --git a/src/crates/contracts/product-domains/src/legacy_migration/mod.rs b/src/crates/contracts/product-domains/src/legacy_migration/mod.rs index 5a050cfe16..fe37c9eb99 100644 --- a/src/crates/contracts/product-domains/src/legacy_migration/mod.rs +++ b/src/crates/contracts/product-domains/src/legacy_migration/mod.rs @@ -295,6 +295,12 @@ pub struct MigrationDomainResult { pub skipped: u64, pub conflicts: u64, pub warnings: Vec, + /// Non-sensitive logical identifiers whose credentials must be entered again. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requires_reauthentication: Vec, + /// Non-sensitive logical identifiers whose filesystem location must be repaired. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requires_relocation: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs b/src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs index 6224c6c873..b6cab3ba2c 100644 --- a/src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs +++ b/src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs @@ -1,7 +1,7 @@ use openbitfun_product_domains::legacy_migration::{ - MigrationDomainId, MigrationGroupId, MigrationOnboardingState, MigrationPromptChoice, - MigrationSelection, MigratorHandoffRequest, MigratorProtocolCapabilities, MigratorRequestMode, - CURRENT_MIGRATOR_PROTOCOL_VERSION, + MigrationDomainId, MigrationDomainResult, MigrationGroupId, MigrationOnboardingState, + MigrationPromptChoice, MigrationSelection, MigratorHandoffRequest, + MigratorProtocolCapabilities, MigratorRequestMode, CURRENT_MIGRATOR_PROTOCOL_VERSION, }; use std::collections::BTreeSet; @@ -31,6 +31,19 @@ fn persisted_onboarding_shape_accepts_old_payloads() { assert!(state.run_id.is_none()); } +#[test] +fn persisted_domain_result_defaults_new_repair_lists() { + let result: MigrationDomainResult = serde_json::from_value(serde_json::json!({ + "domain": "credentials", + "state": "staged", + "imported": 1 + })) + .expect("new repair lists must remain additive"); + + assert!(result.requires_reauthentication.is_empty()); + assert!(result.requires_relocation.is_empty()); +} + #[test] fn handoff_rejects_expired_or_future_protocol_requests() { let request: MigratorHandoffRequest = serde_json::from_value(serde_json::json!({ diff --git a/src/crates/execution/agent-runtime/Cargo.toml b/src/crates/execution/agent-runtime/Cargo.toml index b234c51de3..655f77b920 100644 --- a/src/crates/execution/agent-runtime/Cargo.toml +++ b/src/crates/execution/agent-runtime/Cargo.toml @@ -17,6 +17,13 @@ required-features = ["agent-runtime"] [features] default = [] +definition-contracts = [ + "dep:openbitfun-core-types", + "dep:regex", + "dep:serde", + "dep:serde_yaml", + "dep:thiserror", +] native-hook-settings = ["dep:regex", "dep:serde_json"] native-hook-runtime = [ "native-hook-settings", @@ -33,6 +40,7 @@ native-hook-runtime = [ "tokio/time", ] agent-runtime = [ + "definition-contracts", "native-hook-runtime", "dep:async-trait", "dep:openbitfun-agent-stream", diff --git a/src/crates/execution/agent-runtime/src/lib.rs b/src/crates/execution/agent-runtime/src/lib.rs index 51af96e764..a73173eb32 100644 --- a/src/crates/execution/agent-runtime/src/lib.rs +++ b/src/crates/execution/agent-runtime/src/lib.rs @@ -9,7 +9,7 @@ pub mod agents; pub mod checkpoint; #[cfg(feature = "agent-runtime")] pub mod context_profile; -#[cfg(feature = "agent-runtime")] +#[cfg(any(feature = "agent-runtime", feature = "definition-contracts"))] pub mod custom_agent; #[cfg(feature = "agent-runtime")] pub mod custom_subagent; @@ -39,7 +39,7 @@ pub mod output_surface; pub mod permission; #[cfg(feature = "agent-runtime")] pub mod post_call_hooks; -#[cfg(feature = "agent-runtime")] +#[cfg(any(feature = "agent-runtime", feature = "definition-contracts"))] pub mod prompt; #[cfg(feature = "agent-runtime")] pub mod prompt_cache; @@ -67,7 +67,7 @@ pub mod session_state_manager; pub mod side_question; #[cfg(feature = "agent-runtime")] pub mod skill_agent_snapshot; -#[cfg(feature = "agent-runtime")] +#[cfg(any(feature = "agent-runtime", feature = "definition-contracts"))] pub mod skills; #[cfg(feature = "agent-runtime")] pub mod subagent_task; diff --git a/src/crates/execution/agent-runtime/src/skills/mod.rs b/src/crates/execution/agent-runtime/src/skills/mod.rs index 1a11d20e2a..fad7e3c1c6 100644 --- a/src/crates/execution/agent-runtime/src/skills/mod.rs +++ b/src/crates/execution/agent-runtime/src/skills/mod.rs @@ -5,16 +5,24 @@ //! rendering. Product hosts still own filesystem/config IO and registry //! scanning. +#[cfg(feature = "agent-runtime")] mod catalog; +#[cfg(feature = "agent-runtime")] mod keys; +#[cfg(feature = "agent-runtime")] mod policy; +#[cfg(feature = "agent-runtime")] mod resolver; mod roots; +#[cfg(feature = "agent-runtime")] mod selection; mod types; +#[cfg(feature = "agent-runtime")] pub use catalog::builtin_skill_group_key; +#[cfg(feature = "agent-runtime")] pub use policy::resolve_builtin_default_enabled; +#[cfg(feature = "agent-runtime")] pub use resolver::{ normalize_user_mode_skill_overrides, resolve_skill_default_enabled_for_mode, resolve_skill_state_for_mode, ModeSkillState, UserModeSkillOverrides, @@ -26,6 +34,7 @@ pub use roots::{ OPENBITFUN_USER_SKILL_SLOT, PROJECT_SKILL_KEY_PREFIX, PROJECT_SKILL_ROOTS, USER_CONFIG_SKILL_ROOTS, USER_HOME_SKILL_ROOTS, USER_SKILL_KEY_PREFIX, }; +#[cfg(feature = "agent-runtime")] pub use selection::{ annotate_shadowed_skills, build_mode_skill_infos, filter_candidates_for_mode, filter_implicitly_invocable_skills, filter_user_invocable_skills, is_skill_globally_enabled, diff --git a/src/crates/services/legacy-migration/src/engine.rs b/src/crates/services/legacy-migration/src/engine.rs index 90498da954..76a673e6e6 100644 --- a/src/crates/services/legacy-migration/src/engine.rs +++ b/src/crates/services/legacy-migration/src/engine.rs @@ -54,6 +54,20 @@ pub trait LegacyDomainAdapter: Send + Sync { fn validate_commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()>; + /// Refresh non-sensitive result metadata discovered only while committing. + /// + /// Secret-bearing adapters deliberately defer decryption until `commit`. + /// They can persist a redacted outcome and surface warnings or + /// reauthentication identifiers here after the owner has validated the + /// installed data. + fn finalize_result( + &self, + _context: &DomainContext<'_>, + staged: &MigrationDomainResult, + ) -> LegacyMigrationResult { + Ok(staged.clone()) + } + fn rollback_unverified(&self, _context: &DomainContext<'_>) -> LegacyMigrationResult<()> { Ok(()) } @@ -456,9 +470,42 @@ impl MigrationEngine { step.domain, MigrationPhase::ValidateCommit, )?; + let _ = adapter.rollback_unverified(&context); return Err(domain_error(step.domain, error)); } - report.domain_results[result_index].state = MigrationDomainState::Verified; + let mut finalized = + match adapter.finalize_result(&context, &report.domain_results[result_index]) { + Ok(finalized) => finalized, + Err(error) => { + record_domain_failure( + &layout, + &mut report, + &mut journal_sequence, + result_index, + step.domain, + MigrationPhase::ValidateCommit, + )?; + let _ = adapter.rollback_unverified(&context); + return Err(domain_error(step.domain, error)); + } + }; + if finalized.domain != step.domain { + record_domain_failure( + &layout, + &mut report, + &mut journal_sequence, + result_index, + step.domain, + MigrationPhase::ValidateCommit, + )?; + let _ = adapter.rollback_unverified(&context); + return Err(LegacyMigrationError::InvalidPlan(format!( + "adapter {:?} finalized a result for {:?}", + step.domain, finalized.domain + ))); + } + finalized.state = MigrationDomainState::Verified; + report.domain_results[result_index] = finalized; persist_report(&layout, &report)?; journal( &layout, @@ -477,6 +524,20 @@ impl MigrationEngine { } inject(crash_injector, CrashPoint::BeforeFinalize)?; + report.requires_reauthentication = report + .domain_results + .iter() + .flat_map(|result| result.requires_reauthentication.iter().cloned()) + .collect::>() + .into_iter() + .collect(); + report.requires_relocation = report + .domain_results + .iter() + .flat_map(|result| result.requires_relocation.iter().cloned()) + .collect::>() + .into_iter() + .collect(); report.finished_at_ms = Some(now_ms()); let has_warnings = report.diagnostics.iter().any(|diagnostic| { matches!( diff --git a/src/crates/services/legacy-migration/src/lib.rs b/src/crates/services/legacy-migration/src/lib.rs index d6804b799f..4922affe17 100644 --- a/src/crates/services/legacy-migration/src/lib.rs +++ b/src/crates/services/legacy-migration/src/lib.rs @@ -15,4 +15,4 @@ pub use error::{LegacyMigrationError, LegacyMigrationResult}; pub use paths::{MigrationRoots, LEGACY_PRODUCT_ID}; pub use probe::{probe_legacy_source, ProbeLimits}; pub use sqlite::{snapshot_sqlite_read_only, validate_sqlite}; -pub use storage::{atomic_write_json, MigrationLayout, MigrationLock}; +pub use storage::{atomic_write_bytes, atomic_write_json, MigrationLayout, MigrationLock}; diff --git a/src/crates/services/legacy-migration/src/storage.rs b/src/crates/services/legacy-migration/src/storage.rs index 6d1e7eed82..c94a0cd105 100644 --- a/src/crates/services/legacy-migration/src/storage.rs +++ b/src/crates/services/legacy-migration/src/storage.rs @@ -120,11 +120,17 @@ impl Drop for MigrationLock { } pub fn atomic_write_json(path: &Path, value: &T) -> LegacyMigrationResult<()> { - let bytes = serde_json::to_vec_pretty(value) + let mut bytes = serde_json::to_vec_pretty(value) .map_err(|error| LegacyMigrationError::json(path, error))?; + bytes.push(b'\n'); atomic_write(path, &bytes) } +/// Atomically replace a file with caller-provided bytes on the target volume. +pub fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> LegacyMigrationResult<()> { + atomic_write(path, bytes) +} + fn atomic_write(path: &Path, bytes: &[u8]) -> LegacyMigrationResult<()> { let parent = path.parent().ok_or_else(|| { LegacyMigrationError::InvalidRequest(format!("path has no parent: {}", path.display())) @@ -142,8 +148,6 @@ fn atomic_write(path: &Path, bytes: &[u8]) -> LegacyMigrationResult<()> { .map_err(|error| LegacyMigrationError::io(&temp, error))?; file.write_all(bytes) .map_err(|error| LegacyMigrationError::io(&temp, error))?; - file.write_all(b"\n") - .map_err(|error| LegacyMigrationError::io(&temp, error))?; file.sync_all() .map_err(|error| LegacyMigrationError::io(&temp, error))?; drop(file); diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md index 18d9d6d0eb..293c1c8d52 100644 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md @@ -9,6 +9,10 @@ The fixture is source data, not a ready-to-copy OpenBitFun profile. Tests build SQLite databases from the checked-in SQL, enable WAL where needed, and validate the fixture through the migration readers before executing a plan. +The Memory SQL mirrors the historical `stage1_outputs` and `jobs` owner schema. +Only `stage1_outputs` contains migratable facts; `jobs` is included to prove +that resumable runtime work state is rebuilt instead of imported. + Supported source range for V1 is `>=0.2.0,<1.0.0`. The fixture's canonical source revision is `845b4b4d2925f7c41e7e03a4a618606fbd0da8b6`. diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/fixture-manifest.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/fixture-manifest.json index ccb438fb64..4ddecc9e59 100644 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/fixture-manifest.json +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/fixture-manifest.json @@ -18,7 +18,8 @@ "user-root/data/workspace_data.json", "user-root/data/agent-runtime/coordination.sql", "user-root/data/memories/memories.sql", - "home/projects/workspace-demo/sessions/session-1/metadata.json", + "home/projects/c--fixture-workspace/sessions/session-1/metadata.json", + "home/projects/c--fixture-workspace/sessions/session-child-1/metadata.json", "home/runtime-events/session-1.jsonl", "home/memories/MEMORY.md", "home/device_identity.json", @@ -33,6 +34,6 @@ "user-root/data/agent-runtime/ownership/writer.lock", "user-root/data/agent-runtime/ipc-v17/discovery.json", "user-root/logs/legacy.log", - "home/projects/workspace-demo/sessions/session-1/request-traces/trace.json" + "home/projects/c--fixture-workspace/sessions/session-1/request-traces/trace.json" ] } diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/device_identity.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/device_identity.json index 0e39e057c3..a6a157a559 100644 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/device_identity.json +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/device_identity.json @@ -1 +1,5 @@ -{"device_id":"0123456789abcdef0123456789abcdef"} +{ + "device_id": "0123456789abcdef0123456789abcdef", + "device_name": "fixture-device", + "mac_address": "02:00:00:00:00:19" +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/metadata.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/metadata.json new file mode 100644 index 0000000000..92ce422c0a --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/metadata.json @@ -0,0 +1,19 @@ +{ + "schema_version": 2, + "sessionId": "session-1", + "sessionName": "Synthetic migration fixture", + "agentType": "Agentic", + "sessionKind": "standard", + "memoryMode": "enabled", + "modelName": "fixture-model", + "createdAt": 1, + "lastActiveAt": 2, + "lastFinishedAt": 2, + "turnCount": 1, + "messageCount": 1, + "toolCallCount": 0, + "status": "active", + "tags": [], + "workspacePath": "C:\\fixture\\workspace", + "workspaceHostname": "localhost" +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/request-traces/trace.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/request-traces/trace.json new file mode 100644 index 0000000000..0beb43bf25 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/request-traces/trace.json @@ -0,0 +1 @@ +{"request":"synthetic diagnostic payload that must not migrate"} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/turns/turn-0000.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/turns/turn-0000.json new file mode 100644 index 0000000000..3f356e73ff --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1/turns/turn-0000.json @@ -0,0 +1,19 @@ +{ + "schema_version": 2, + "turnId": "turn-1", + "turnIndex": 0, + "sessionId": "session-1", + "timestamp": 1, + "agentType": "Agentic", + "userMessage": { + "id": "message-1", + "content": "Synthetic parent request", + "timestamp": 1 + }, + "modelRounds": [], + "startTime": 1, + "endTime": 2, + "durationMs": 1, + "hasFinalResponse": true, + "status": "completed" +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-child-1/metadata.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-child-1/metadata.json new file mode 100644 index 0000000000..56eb764026 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-child-1/metadata.json @@ -0,0 +1,27 @@ +{ + "schema_version": 2, + "sessionId": "session-child-1", + "sessionName": "Subagent: Synthetic helper", + "agentType": "Explore", + "createdBy": "session-1", + "sessionKind": "subagent", + "memoryMode": "enabled", + "modelName": "fixture-model", + "createdAt": 1, + "lastActiveAt": 2, + "lastFinishedAt": 2, + "turnCount": 1, + "messageCount": 1, + "toolCallCount": 0, + "status": "completed", + "tags": [], + "relationship": { + "kind": "subagent", + "parentSessionId": "session-1", + "parentDialogTurnId": "turn-1", + "parentToolCallId": "call-1", + "subagentType": "Explore" + }, + "workspacePath": "C:\\fixture\\workspace", + "workspaceHostname": "localhost" +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-child-1/turns/turn-0000.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-child-1/turns/turn-0000.json new file mode 100644 index 0000000000..40844f4782 --- /dev/null +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-child-1/turns/turn-0000.json @@ -0,0 +1,19 @@ +{ + "schema_version": 2, + "turnId": "turn-child-1", + "turnIndex": 0, + "sessionId": "session-child-1", + "timestamp": 1, + "agentType": "Explore", + "userMessage": { + "id": "message-child-1", + "content": "Synthetic delegated request", + "timestamp": 1 + }, + "modelRounds": [], + "startTime": 1, + "endTime": 2, + "durationMs": 1, + "hasFinalResponse": true, + "status": "completed" +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/metadata.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/metadata.json deleted file mode 100644 index 70e4ec209f..0000000000 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/metadata.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "id": "session-1", - "workspacePath": "C:\\fixture\\workspace", - "title": "Synthetic migration fixture", - "createdAt": 1, - "updatedAt": 2 -} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/request-traces/trace.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/request-traces/trace.json deleted file mode 100644 index 36e6f637da..0000000000 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/request-traces/trace.json +++ /dev/null @@ -1 +0,0 @@ -{"diagnostic":"must not migrate"} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/turns/turn-1.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/turns/turn-1.json deleted file mode 100644 index 08612d0907..0000000000 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/projects/workspace-demo/sessions/session-1/turns/turn-1.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"turn-1","sessionId":"session-1","status":"completed","items":[]} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/remote_connect_persistence.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/remote_connect_persistence.json index 7bb5c613c8..fec7c4bf3a 100644 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/remote_connect_persistence.json +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/remote_connect_persistence.json @@ -2,10 +2,29 @@ "connections": [ { "bot_type": "telegram", - "display_name": "Synthetic bot", - "enabled": false, - "requires_reauthentication": true + "chat_id": "fixture-chat", + "config": { + "bot_type": "telegram", + "bot_token": "" + }, + "chat_state": { + "chat_id": "fixture-chat", + "paired": false, + "current_workspace": "/srv/fixture-workspace", + "current_assistant": null, + "current_assistant_name": null, + "current_session_id": null, + "display_mode": "assistant", + "account_remote_context": true + }, + "connected_at": 0 } ], - "account_remote_context": true + "form_state": { + "custom_server_url": "https://relay.example.invalid", + "telegram_bot_token": "", + "feishu_app_id": "", + "feishu_app_secret": "" + }, + "verbose_mode": false } diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/runtime-events/session-1.jsonl b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/runtime-events/session-1.jsonl index 66f821cdc0..b32a5b8ff8 100644 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/runtime-events/session-1.jsonl +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/home/runtime-events/session-1.jsonl @@ -1 +1 @@ -{"sessionId":"session-1","turnId":"turn-1","event":"settled"} +{"streamId":"fixture-stream","cursor":1,"event":{"type":"TextChunk","session_id":"session-1","turn_id":"turn-runtime-1","round_id":"round-runtime-1","text":"Synthetic in-flight output"}} diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/known_hosts b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/known_hosts index 4ff70ff4a8..df11902273 100644 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/known_hosts +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/known_hosts @@ -1 +1,9 @@ -[example.invalid]:22 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixtureOnlyNotARealHostKey +[ + { + "host": "example.invalid", + "port": 22, + "key_type": "ssh-ed25519", + "fingerprint": "SHA256:fixture-only-host-fingerprint", + "public_key": "00010203040506070809" + } +] diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/remote_workspace.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/remote_workspace.json index 0f64ab2ba8..2a8a9ec504 100644 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/remote_workspace.json +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/remote_workspace.json @@ -1,4 +1,6 @@ { "connectionId": "ssh-fixture@example.invalid:22", - "remotePath": "/srv/fixture-workspace" + "remotePath": "/srv/fixture-workspace", + "connectionName": "fixture@example.invalid", + "sshHost": "example.invalid" } diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/ssh_connections.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/ssh_connections.json index 0ebd6e24e9..fd3cac716e 100644 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/ssh_connections.json +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/ssh/ssh_connections.json @@ -1,9 +1,14 @@ [ { "id": "ssh-fixture@example.invalid:22", + "name": "fixture@example.invalid", "host": "example.invalid", "port": 22, "username": "fixture", - "auth_type": "password" + "authType": { + "type": "Password" + }, + "defaultWorkspace": "/srv/fixture-workspace", + "lastConnected": 1 } ] diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/coordination.sql b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/coordination.sql index e37acd349a..1dfa315de4 100644 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/coordination.sql +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/agent-runtime/coordination.sql @@ -42,4 +42,5 @@ INSERT INTO coordination_sessions VALUES ('session-1', 2, 1); INSERT INTO agents VALUES (1, 'session-1', 'helper-1', 'session-child-1', 2, 'historical', 1); INSERT INTO background_tasks VALUES (1, 'session-1', 1, 'bg-1', 1, 'turn-1', 'call-1', 'turn-child-1', 'completed', NULL, NULL, 'fixture-owner', 1, 2); INSERT INTO swarm_trees VALUES ('session-1', 1); +INSERT INTO swarm_nodes VALUES ('session-1', 'session-1', NULL, 'Ultra', 0, 1); INSERT INTO swarm_nodes VALUES ('session-child-1', 'session-1', 'session-1', 'researcher', 1, 1); diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/memories/memories.sql b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/memories/memories.sql index 764d56cd62..3e87d0db5a 100644 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/memories/memories.sql +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/memories/memories.sql @@ -1,6 +1,57 @@ -CREATE TABLE memories ( - id TEXT PRIMARY KEY, - content TEXT NOT NULL, - created_at INTEGER NOT NULL +CREATE TABLE stage1_outputs ( + thread_id TEXT PRIMARY KEY NOT NULL, + workspace_path TEXT NOT NULL, + rollout_path TEXT NOT NULL, + source_updated_at INTEGER NOT NULL, + raw_memory TEXT NOT NULL, + rollout_summary TEXT NOT NULL, + rollout_slug TEXT, + generated_at INTEGER NOT NULL, + usage_count INTEGER, + last_usage INTEGER, + selected_for_phase2 INTEGER NOT NULL DEFAULT 0, + selected_for_phase2_source_updated_at INTEGER ); -INSERT INTO memories VALUES ('memory-1', 'Synthetic migration fixture memory.', 1); + +CREATE INDEX idx_stage1_outputs_source_updated_at + ON stage1_outputs(source_updated_at DESC, thread_id DESC); + +CREATE TABLE jobs ( + kind TEXT NOT NULL, + job_key TEXT NOT NULL, + status TEXT NOT NULL, + worker_id TEXT, + ownership_token TEXT, + started_at INTEGER, + finished_at INTEGER, + lease_until INTEGER, + retry_at INTEGER, + retry_remaining INTEGER NOT NULL, + last_error TEXT, + input_watermark INTEGER, + last_success_watermark INTEGER, + PRIMARY KEY (kind, job_key) +); + +CREATE INDEX idx_jobs_kind_status_retry_lease + ON jobs(kind, status, retry_at, lease_until); + +INSERT INTO stage1_outputs VALUES ( + 'session-1', + 'C:\fixture-workspace', + 'C:\fixture-workspace\sessions\session-1', + 1, + 'Synthetic migration fixture memory.', + 'Synthetic migration fixture summary.', + 'fixture-memory', + 2, + 0, + NULL, + 0, + NULL +); + +INSERT INTO jobs ( + kind, job_key, status, retry_remaining, input_watermark, + last_success_watermark +) VALUES ('memory_stage1', 'session-1', 'done', 3, 1, 1); diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/workspace_data.json b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/workspace_data.json index 737f564a96..c33628ff81 100644 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/workspace_data.json +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/user-root/data/workspace_data.json @@ -3,8 +3,20 @@ "workspace-1": { "id": "workspace-1", "name": "Fixture workspace", - "path": "C:\\fixture\\workspace", - "workspace_type": "local" + "rootPath": "C:\\fixture\\workspace", + "workspaceType": "Other", + "workspaceKind": "normal", + "status": "Inactive", + "languages": [], + "openedAt": "2026-01-01T00:00:00Z", + "lastAccessed": "2026-01-01T00:00:00Z", + "description": null, + "tags": [], + "statistics": null, + "relatedPaths": [], + "metadata": { + "sshHost": "localhost" + } } }, "opened_workspace_ids": ["workspace-1"], diff --git a/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs b/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs index 85d91412e6..97da5717de 100644 --- a/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs +++ b/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs @@ -5,8 +5,9 @@ use openbitfun_legacy_migration::{ MigrationRoots, NoCrashInjection, ProbeLimits, }; use openbitfun_product_domains::legacy_migration::{ - MigrationDomainId, MigrationDomainResult, MigrationDomainState, MigrationGroupId, - MigrationRunStatus, MigrationSelection, ScanFinding, + FindingSeverity, MigrationDiagnostic, MigrationDomainId, MigrationDomainResult, + MigrationDomainState, MigrationGroupId, MigrationRunReport, MigrationRunStatus, + MigrationSelection, ScanFinding, }; use rusqlite::Connection; use sha2::{Digest, Sha256}; @@ -23,11 +24,22 @@ struct CallCounts { validate_stage: usize, commit: usize, validate_commit: usize, + finalize_result: usize, + rollback: usize, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum FinalizeBehavior { + #[default] + PassThrough, + AddRedactedMetadata, + Fail, } struct FakeAdapter { domain: MigrationDomainId, calls: Arc>>, + finalize_behavior: FinalizeBehavior, } impl LegacyDomainAdapter for FakeAdapter { @@ -89,6 +101,47 @@ impl LegacyDomainAdapter for FakeAdapter { self.update(|counts| counts.validate_commit += 1); require_file(&target_path(context, self.domain)) } + + fn finalize_result( + &self, + _context: &DomainContext<'_>, + staged: &MigrationDomainResult, + ) -> LegacyMigrationResult { + self.update(|counts| counts.finalize_result += 1); + match self.finalize_behavior { + FinalizeBehavior::PassThrough => Ok(staged.clone()), + FinalizeBehavior::AddRedactedMetadata => { + let mut finalized = staged.clone(); + finalized.imported = 2; + finalized.skipped = 1; + finalized.warnings.push(MigrationDiagnostic { + code: "credential_requires_reauthentication".to_string(), + severity: FindingSeverity::Warning, + domain: Some(self.domain), + message: "A credential must be entered again.".to_string(), + ..MigrationDiagnostic::default() + }); + finalized.requires_reauthentication = vec!["account-1".to_string()]; + Ok(finalized) + } + FinalizeBehavior::Fail => Err(LegacyMigrationError::InvalidRequest( + "final result is unavailable".to_string(), + )), + } + } + + fn rollback_unverified(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { + self.update(|counts| counts.rollback += 1); + let path = target_path(context, self.domain); + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(LegacyMigrationError::InvalidRequest(format!( + "failed to roll back fake owner file {}: {error}", + path.display() + ))), + } + } } impl FakeAdapter { @@ -169,6 +222,118 @@ fn engine_recovers_after_commit_before_journal_and_deduplicates_repeated_runs() assert!(roots.legacy_user_root.join("config/app.json").exists()); } +#[test] +fn engine_persists_owner_metadata_finalized_after_commit_validation() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + seed_supported_source(&roots); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .expect("probe should succeed") + .expect("source should be present"); + let calls = Arc::new(Mutex::new(BTreeMap::new())); + let engine = fake_engine_with_finalize( + roots.clone(), + Arc::clone(&calls), + FinalizeBehavior::AddRedactedMetadata, + ); + let plan = engine + .plan( + &source, + MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }, + &CancellationToken::default(), + ) + .expect("plan should succeed"); + + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .expect("execution should succeed"); + + assert_eq!(report.status, MigrationRunStatus::CompletedWithWarnings); + assert_eq!(report.requires_reauthentication, ["account-1"]); + let settings = report + .domain_results + .iter() + .find(|result| result.domain == MigrationDomainId::Settings) + .expect("settings result should exist"); + assert_eq!(settings.state, MigrationDomainState::Verified); + assert_eq!(settings.imported, 2); + assert_eq!(settings.skipped, 1); + assert_eq!(settings.warnings.len(), 1); + assert_eq!( + calls + .lock() + .expect("calls should not poison") + .get(&MigrationDomainId::Settings) + .expect("settings calls") + .finalize_result, + 1 + ); + + let persisted: MigrationRunReport = serde_json::from_slice( + &fs::read(MigrationLayout::new(&roots, &plan.run_id).report_path()) + .expect("report should be persisted"), + ) + .expect("persisted report should be valid"); + assert_eq!(persisted, report); +} + +#[test] +fn engine_rolls_back_unverified_commit_when_owner_finalization_fails() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + seed_supported_source(&roots); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .expect("probe should succeed") + .expect("source should be present"); + let calls = Arc::new(Mutex::new(BTreeMap::new())); + let engine = + fake_engine_with_finalize(roots.clone(), Arc::clone(&calls), FinalizeBehavior::Fail); + let plan = engine + .plan( + &source, + MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }, + &CancellationToken::default(), + ) + .expect("plan should succeed"); + + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &NoCrashInjection), + Err(LegacyMigrationError::Domain { + domain: MigrationDomainId::Settings, + .. + }) + )); + assert!(!target_path_for_roots(&roots, MigrationDomainId::Settings).exists()); + let settings_calls = calls + .lock() + .expect("calls should not poison") + .get(&MigrationDomainId::Settings) + .copied() + .expect("settings calls"); + assert_eq!(settings_calls.finalize_result, 1); + assert_eq!(settings_calls.rollback, 1); + + let persisted: MigrationRunReport = serde_json::from_slice( + &fs::read(MigrationLayout::new(&roots, &plan.run_id).report_path()) + .expect("failed report should be persisted"), + ) + .expect("persisted report should be valid"); + assert_eq!(persisted.status, MigrationRunStatus::FailedRecoverable); + assert_eq!( + persisted + .domain_results + .iter() + .find(|result| result.domain == MigrationDomainId::Settings) + .expect("settings result should exist") + .state, + MigrationDomainState::Failed + ); +} + #[test] fn sqlite_wal_snapshot_contains_committed_rows_without_changing_source_files() { let temp = test_tempdir(); @@ -235,6 +400,14 @@ fn lock_contention_and_source_target_aliases_fail_closed() { fn fake_engine( roots: MigrationRoots, calls: Arc>>, +) -> MigrationEngine { + fake_engine_with_finalize(roots, calls, FinalizeBehavior::PassThrough) +} + +fn fake_engine_with_finalize( + roots: MigrationRoots, + calls: Arc>>, + settings_finalize_behavior: FinalizeBehavior, ) -> MigrationEngine { let adapters = [ MigrationDomainId::Settings, @@ -246,6 +419,11 @@ fn fake_engine( Box::new(FakeAdapter { domain, calls: Arc::clone(&calls), + finalize_behavior: if domain == MigrationDomainId::Settings { + settings_finalize_behavior + } else { + FinalizeBehavior::PassThrough + }, }) as Box }); MigrationEngine::new(roots, adapters).expect("fake engine should be valid") @@ -276,8 +454,11 @@ fn stage_path(context: &DomainContext<'_>, domain: MigrationDomainId) -> PathBuf } fn target_path(context: &DomainContext<'_>, domain: MigrationDomainId) -> PathBuf { - context - .roots + target_path_for_roots(context.roots, domain) +} + +fn target_path_for_roots(roots: &MigrationRoots, domain: MigrationDomainId) -> PathBuf { + roots .target_user_root .join("data/fake-owner") .join(format!("{domain:?}.json")) diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index b4055886a8..919d369a94 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -4,8 +4,8 @@ Scope: this guide applies to `src/crates/services/services-core`. `openbitfun-services-core` owns cross-platform service DTOs and helpers that compile without the full product runtime. This includes generic filesystem/search/JSON -IO helpers, bounded local Instruction file reads, session metadata storage -helpers, and local OS action primitives such as command lookup, +IO helpers, bounded local Instruction file reads, Session metadata storage +helpers, the durable Memory SQLite format, and local OS action primitives such as command lookup, clipboard, file/url opening, script execution, workspace runtime FS/shell providers, process-wide TLS provider selection, managed process-tree lifecycle, process-level Agent Runtime ownership locks, and system facts. Product crates may layer routing, policy, @@ -30,7 +30,7 @@ crate. `permission`, `dispatch-workspace`, `markdown`, `session-git`, and `workspace-text-runtime` extensions only for behavior they use. Products needing IANA time-zone ranges and dashboard aggregation additionally select - `token-usage-statistics`. In particular, session metadata consumers must + `token-usage-statistics` and `memory-store`. In particular, session metadata consumers must not compile libgit2 unless they use the memory-workspace baseline/diff API. Keep Tokio and platform API capabilities owner-scoped too: the empty profile carries no Tokio dependency, `workspace-runtime` explicitly composes @@ -91,6 +91,7 @@ cargo test -p openbitfun-services-core --no-default-features --features workspac cargo test -p openbitfun-services-core --no-default-features --features workspace-runtime --lib workspace::tests:: cargo test -p openbitfun-services-core --no-default-features --features local-storage --test session_contracts session_metadata_contracts:: cargo test -p openbitfun-services-core --no-default-features --features local-storage --test session_write_lock_contracts +cargo test -p openbitfun-services-core --no-default-features --features memory-store --lib memory_store::tests:: cargo test -p openbitfun-services-core --no-default-features --features token-usage-statistics --lib token_usage:: cargo test -p openbitfun-services-core --no-default-features --features process-runtime --test process_runtime_contracts cargo test -p openbitfun-services-core --no-default-features --features process-runtime --lib process_tree::tests:: diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index fad014f208..ab1761509c 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -105,6 +105,7 @@ local-storage = [ "windows/Win32_Foundation", "windows/Win32_Storage_FileSystem", ] +memory-store = ["dep:rusqlite"] product-identity = ["dep:openbitfun-core-types"] token-usage-statistics = ["local-storage", "dep:chrono-tz"] process-runtime = [ diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index a7798b327c..070c54f100 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -29,6 +29,8 @@ pub mod local_runtime_ports; pub mod managed_runtime; #[cfg(feature = "markdown")] pub mod markdown; +#[cfg(feature = "memory-store")] +pub mod memory_store; #[cfg(feature = "permission")] pub mod permission_store; #[cfg(feature = "local-storage")] diff --git a/src/crates/services/services-core/src/memory_store.rs b/src/crates/services/services-core/src/memory_store.rs new file mode 100644 index 0000000000..85744e4fcf --- /dev/null +++ b/src/crates/services/services-core/src/memory_store.rs @@ -0,0 +1,456 @@ +//! Durable Memory store schema and synchronous owner access. +//! +//! The live Memory workflow remains in Product Assembly. This module owns the +//! SQLite persistence shape and the small synchronous API needed by both the +//! live runtime and the offline legacy-data migrator. + +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use std::path::{Component, Path}; +use thiserror::Error; + +pub const MEMORY_STORE_SCHEMA: &str = "openbitfun.memory.stage1.v1"; +pub const MEMORY_FILE_NAME: &str = "MEMORY.md"; +pub const MEMORY_SUMMARY_FILE_NAME: &str = "memory_summary.md"; +pub const MEMORY_EXTENSIONS_DIR_NAME: &str = "extensions"; +pub const AD_HOC_EXTENSION_NAME: &str = "ad_hoc"; +pub const AD_HOC_NOTES_DIR_NAME: &str = "notes"; + +pub const EXPECTED_STAGE1_COLUMNS: &[&str] = &[ + "thread_id", + "workspace_path", + "rollout_path", + "source_updated_at", + "raw_memory", + "rollout_summary", + "rollout_slug", + "generated_at", + "usage_count", + "last_usage", + "selected_for_phase2", + "selected_for_phase2_source_updated_at", +]; + +pub const EXPECTED_JOBS_COLUMNS: &[&str] = &[ + "kind", + "job_key", + "status", + "worker_id", + "ownership_token", + "started_at", + "finished_at", + "lease_until", + "retry_at", + "retry_remaining", + "last_error", + "input_watermark", + "last_success_watermark", +]; + +const JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL: &str = "memory_consolidate_global"; +const JOB_STATUS_DONE: &str = "done"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryRecord { + pub session_id: String, + pub workspace_path: String, + pub rollout_path: String, + pub source_updated_at_unix_secs: i64, + pub raw_memory: String, + pub rollout_summary: String, + pub rollout_slug: Option, + pub generated_at_unix_secs: i64, + pub usage_count: i64, + pub last_usage_unix_secs: Option, + pub selected_for_phase2: i64, + pub selected_for_phase2_source_updated_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryJobRecord { + pub kind: String, + pub job_key: String, + pub status: String, + pub worker_id: Option, + pub ownership_token: Option, + pub started_at_unix_secs: Option, + pub finished_at_unix_secs: Option, + pub lease_until_unix_secs: Option, + pub retry_at_unix_secs: Option, + pub retry_remaining: i64, + pub last_error: Option, + pub input_watermark: Option, + pub last_success_watermark: Option, +} + +impl MemoryJobRecord { + pub fn success_cooldown_until_unix_secs(&self, cooldown_seconds: i64) -> Option { + if self.kind != JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL + || self.status != JOB_STATUS_DONE + || self.last_error.is_some() + || self.input_watermark.is_none() + || self.last_success_watermark != self.input_watermark + { + return None; + } + + self.finished_at_unix_secs + .map(|finished_at| finished_at.saturating_add(cooldown_seconds.max(0))) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryStoreSnapshot { + pub records: Vec, + pub jobs: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MemoryWorkspaceFileKind { + Index, + Summary, + AdHocNote, +} + +#[derive(Debug, Error)] +pub enum MemoryStoreError { + #[error("Memory store SQLite operation failed: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("Memory store schema is unsupported: {0}")] + UnsupportedSchema(String), + #[error("Memory record is invalid: {0}")] + InvalidRecord(String), +} + +pub fn initialize_memory_schema(conn: &Connection) -> Result<(), MemoryStoreError> { + recreate_table_if_shape_differs(conn, "stage1_outputs", EXPECTED_STAGE1_COLUMNS)?; + recreate_table_if_shape_differs(conn, "jobs", EXPECTED_JOBS_COLUMNS)?; + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS stage1_outputs ( + thread_id TEXT PRIMARY KEY NOT NULL, + workspace_path TEXT NOT NULL, + rollout_path TEXT NOT NULL, + source_updated_at INTEGER NOT NULL, + raw_memory TEXT NOT NULL, + rollout_summary TEXT NOT NULL, + rollout_slug TEXT, + generated_at INTEGER NOT NULL, + usage_count INTEGER, + last_usage INTEGER, + selected_for_phase2 INTEGER NOT NULL DEFAULT 0, + selected_for_phase2_source_updated_at INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_stage1_outputs_source_updated_at + ON stage1_outputs(source_updated_at DESC, thread_id DESC); + + CREATE TABLE IF NOT EXISTS jobs ( + kind TEXT NOT NULL, + job_key TEXT NOT NULL, + status TEXT NOT NULL, + worker_id TEXT, + ownership_token TEXT, + started_at INTEGER, + finished_at INTEGER, + lease_until INTEGER, + retry_at INTEGER, + retry_remaining INTEGER NOT NULL, + last_error TEXT, + input_watermark INTEGER, + last_success_watermark INTEGER, + PRIMARY KEY (kind, job_key) + ); + + CREATE INDEX IF NOT EXISTS idx_jobs_kind_status_retry_lease + ON jobs(kind, status, retry_at, lease_until); + "#, + )?; + Ok(()) +} + +pub fn validate_memory_schema(conn: &Connection) -> Result<(), MemoryStoreError> { + validate_table_shape(conn, "stage1_outputs", EXPECTED_STAGE1_COLUMNS)?; + validate_table_shape(conn, "jobs", EXPECTED_JOBS_COLUMNS) +} + +pub fn read_memory_store_snapshot( + conn: &Connection, +) -> Result { + validate_memory_schema(conn)?; + let mut record_statement = conn.prepare( + r#" + SELECT thread_id, workspace_path, rollout_path, source_updated_at, raw_memory, + rollout_summary, rollout_slug, generated_at, COALESCE(usage_count, 0), + last_usage, selected_for_phase2, selected_for_phase2_source_updated_at + FROM stage1_outputs + ORDER BY thread_id + "#, + )?; + let records = record_statement + .query_map([], decode_memory_record)? + .collect::, _>>()?; + for record in &records { + validate_memory_record(record)?; + } + + let mut job_statement = conn.prepare( + r#" + SELECT kind, job_key, status, worker_id, ownership_token, started_at, + finished_at, lease_until, retry_at, retry_remaining, last_error, + input_watermark, last_success_watermark + FROM jobs + ORDER BY kind, job_key + "#, + )?; + let jobs = job_statement + .query_map([], decode_memory_job)? + .collect::, _>>()?; + Ok(MemoryStoreSnapshot { records, jobs }) +} + +pub fn upsert_memory_record( + conn: &Connection, + record: &MemoryRecord, + overwrite_usage_and_selection: bool, +) -> Result<(), MemoryStoreError> { + let overwrite = i64::from(overwrite_usage_and_selection); + conn.execute( + UPSERT_STAGE1_OUTPUT_SQL, + params![ + &record.session_id, + &record.workspace_path, + &record.rollout_path, + record.source_updated_at_unix_secs, + &record.raw_memory, + &record.rollout_summary, + &record.rollout_slug, + record.generated_at_unix_secs, + record.usage_count, + record.last_usage_unix_secs, + record.selected_for_phase2, + record.selected_for_phase2_source_updated_at, + overwrite, + overwrite, + overwrite, + overwrite, + ], + )?; + Ok(()) +} + +pub fn decode_memory_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(MemoryRecord { + session_id: row.get(0)?, + workspace_path: row.get(1)?, + rollout_path: row.get(2)?, + source_updated_at_unix_secs: row.get(3)?, + raw_memory: row.get(4)?, + rollout_summary: row.get(5)?, + rollout_slug: row.get(6)?, + generated_at_unix_secs: row.get(7)?, + usage_count: row.get(8)?, + last_usage_unix_secs: row.get(9)?, + selected_for_phase2: row.get(10)?, + selected_for_phase2_source_updated_at: row.get(11)?, + }) +} + +pub fn decode_memory_job(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(MemoryJobRecord { + kind: row.get(0)?, + job_key: row.get(1)?, + status: row.get(2)?, + worker_id: row.get(3)?, + ownership_token: row.get(4)?, + started_at_unix_secs: row.get(5)?, + finished_at_unix_secs: row.get(6)?, + lease_until_unix_secs: row.get(7)?, + retry_at_unix_secs: row.get(8)?, + retry_remaining: row.get(9)?, + last_error: row.get(10)?, + input_watermark: row.get(11)?, + last_success_watermark: row.get(12)?, + }) +} + +pub fn classify_memory_workspace_file(path: &Path) -> Option { + let components = path.components().collect::>(); + if components + .iter() + .any(|component| !matches!(component, Component::Normal(_))) + { + return None; + } + if components.len() == 1 { + let file_name = components[0].as_os_str().to_str()?; + return match file_name { + MEMORY_FILE_NAME => Some(MemoryWorkspaceFileKind::Index), + MEMORY_SUMMARY_FILE_NAME => Some(MemoryWorkspaceFileKind::Summary), + _ => None, + }; + } + if components.len() == 4 + && components[0].as_os_str() == MEMORY_EXTENSIONS_DIR_NAME + && components[1].as_os_str() == AD_HOC_EXTENSION_NAME + && components[2].as_os_str() == AD_HOC_NOTES_DIR_NAME + { + let file_name = components[3].as_os_str().to_str()?; + if !file_name.is_empty() && file_name.ends_with(".md") { + return Some(MemoryWorkspaceFileKind::AdHocNote); + } + } + None +} + +fn validate_memory_record(record: &MemoryRecord) -> Result<(), MemoryStoreError> { + if record.session_id.trim().is_empty() { + return Err(MemoryStoreError::InvalidRecord( + "thread_id must not be empty".to_string(), + )); + } + if !matches!(record.selected_for_phase2, 0 | 1) { + return Err(MemoryStoreError::InvalidRecord(format!( + "selected_for_phase2 must be 0 or 1 for thread_id {}", + record.session_id + ))); + } + Ok(()) +} + +fn validate_table_shape( + conn: &Connection, + table_name: &str, + expected_columns: &[&str], +) -> Result<(), MemoryStoreError> { + let actual = table_columns(conn, table_name)?; + let expected = expected_columns + .iter() + .map(|column| column.to_string()) + .collect::>(); + if actual != expected { + return Err(MemoryStoreError::UnsupportedSchema(format!( + "table {table_name} has columns {actual:?}, expected {expected:?}" + ))); + } + Ok(()) +} + +fn recreate_table_if_shape_differs( + conn: &Connection, + table_name: &str, + expected_columns: &[&str], +) -> Result<(), MemoryStoreError> { + let columns = table_columns(conn, table_name)?; + let expected = expected_columns + .iter() + .map(|column| column.to_string()) + .collect::>(); + if !columns.is_empty() && columns != expected { + conn.execute(&format!("DROP TABLE IF EXISTS {table_name}"), [])?; + } + Ok(()) +} + +fn table_columns(conn: &Connection, table_name: &str) -> Result, MemoryStoreError> { + let mut statement = conn.prepare(&format!("PRAGMA table_info({table_name})"))?; + let rows = statement.query_map([], |row| row.get::<_, String>(1))?; + Ok(rows.collect::, _>>()?) +} + +const UPSERT_STAGE1_OUTPUT_SQL: &str = r#" + INSERT INTO stage1_outputs ( + thread_id, workspace_path, rollout_path, source_updated_at, raw_memory, + rollout_summary, rollout_slug, generated_at, usage_count, + last_usage, selected_for_phase2, selected_for_phase2_source_updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(thread_id) DO UPDATE SET + workspace_path = excluded.workspace_path, + rollout_path = excluded.rollout_path, + source_updated_at = excluded.source_updated_at, + raw_memory = excluded.raw_memory, + rollout_summary = excluded.rollout_summary, + rollout_slug = excluded.rollout_slug, + generated_at = excluded.generated_at, + usage_count = CASE + WHEN ? != 0 THEN excluded.usage_count + ELSE stage1_outputs.usage_count + END, + last_usage = CASE + WHEN ? != 0 THEN excluded.last_usage + ELSE stage1_outputs.last_usage + END, + selected_for_phase2 = CASE + WHEN ? != 0 THEN excluded.selected_for_phase2 + ELSE stage1_outputs.selected_for_phase2 + END, + selected_for_phase2_source_updated_at = CASE + WHEN ? != 0 THEN excluded.selected_for_phase2_source_updated_at + ELSE stage1_outputs.selected_for_phase2_source_updated_at + END + WHERE excluded.source_updated_at >= stage1_outputs.source_updated_at +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn owner_schema_round_trips_records_and_keeps_jobs_separate() { + let connection = Connection::open_in_memory().unwrap(); + initialize_memory_schema(&connection).unwrap(); + let record = MemoryRecord { + session_id: "session-1".to_string(), + workspace_path: "/workspace".to_string(), + rollout_path: "/workspace/sessions/session-1".to_string(), + source_updated_at_unix_secs: 10, + raw_memory: "durable fact".to_string(), + rollout_summary: "summary".to_string(), + rollout_slug: Some("fixture".to_string()), + generated_at_unix_secs: 11, + usage_count: 2, + last_usage_unix_secs: Some(12), + selected_for_phase2: 1, + selected_for_phase2_source_updated_at: Some(10), + }; + upsert_memory_record(&connection, &record, true).unwrap(); + connection + .execute( + "INSERT INTO jobs (kind, job_key, status, retry_remaining) VALUES ('memory_stage1', 'session-1', 'done', 3)", + [], + ) + .unwrap(); + + let snapshot = read_memory_store_snapshot(&connection).unwrap(); + assert_eq!(snapshot.records, vec![record]); + assert_eq!(snapshot.jobs.len(), 1); + assert_eq!(snapshot.jobs[0].job_key, "session-1"); + } + + #[test] + fn workspace_file_contract_accepts_only_durable_owner_inputs() { + assert_eq!( + classify_memory_workspace_file(Path::new("MEMORY.md")), + Some(MemoryWorkspaceFileKind::Index) + ); + assert_eq!( + classify_memory_workspace_file(Path::new( + "extensions/ad_hoc/notes/2026-01-01T00-00-00-note.md" + )), + Some(MemoryWorkspaceFileKind::AdHocNote) + ); + assert_eq!( + classify_memory_workspace_file(Path::new("raw_memories.md")), + None + ); + assert_eq!( + classify_memory_workspace_file(Path::new("rollout_summaries/generated.md")), + None + ); + assert_eq!( + classify_memory_workspace_file(Path::new("../MEMORY.md")), + None + ); + } +} diff --git a/src/crates/services/services-core/src/session/mod.rs b/src/crates/services/services-core/src/session/mod.rs index 7136ef7a6b..7d4736fd75 100644 --- a/src/crates/services/services-core/src/session/mod.rs +++ b/src/crates/services/services-core/src/session/mod.rs @@ -4,6 +4,7 @@ mod lineage; mod memory_workspace; mod metadata; mod metadata_store; +mod offline_import; pub mod page; pub mod types; mod write_lock; @@ -30,6 +31,9 @@ pub use metadata::{ SessionMetadataBuildFacts, }; pub use metadata_store::{SessionMetadataStore, SessionMetadataStoreError}; +pub use offline_import::{ + OfflineSessionBundle, OfflineSessionImportError, OfflineSessionImportStore, +}; pub use openbitfun_core_types::{SessionKind, SESSION_PROVIDER_ACP, SESSION_PROVIDER_METADATA_KEY}; pub use page::{build_session_metadata_page, empty_session_metadata_page, SessionMetadataPage}; pub use types::*; diff --git a/src/crates/services/services-core/src/session/offline_import.rs b/src/crates/services/services-core/src/session/offline_import.rs new file mode 100644 index 0000000000..71f321fb16 --- /dev/null +++ b/src/crates/services/services-core/src/session/offline_import.rs @@ -0,0 +1,230 @@ +//! Offline Session bundle writer and validator. +//! +//! The standalone data migrator cannot run the live Session runtime. It still +//! writes metadata and Turn envelopes through the same storage owners so a +//! successful import is immediately readable by Desktop, CLI, and Server. + +use super::{ + DialogTurnData, SessionMetadata, SessionMetadataStore, SessionMetadataStoreError, + SessionStorageLayout, StoredDialogTurnFile, StoredSessionMetadataFile, + SESSION_STORAGE_SCHEMA_VERSION, +}; +use crate::json_store::{JsonFileStore, JsonFileStoreError}; +use openbitfun_core_types::validate_session_id; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +#[derive(Debug, Clone)] +pub struct OfflineSessionBundle { + pub metadata: SessionMetadata, + pub turns: Vec, +} + +impl OfflineSessionBundle { + pub fn validate(&self) -> Result<(), OfflineSessionImportError> { + validate_bundle_shape(self) + } +} + +#[derive(Debug, Error)] +pub enum OfflineSessionImportError { + #[error(transparent)] + Metadata(#[from] SessionMetadataStoreError), + #[error(transparent)] + Json(#[from] JsonFileStoreError), + #[error("Invalid Session bundle: {0}")] + InvalidBundle(String), +} + +#[derive(Debug, Clone)] +pub struct OfflineSessionImportStore { + layout: SessionStorageLayout, + metadata_store: SessionMetadataStore, + json_store: JsonFileStore, +} + +impl OfflineSessionImportStore { + pub fn new(sessions_root: impl Into) -> Self { + let sessions_root = sessions_root.into(); + Self { + layout: SessionStorageLayout::new(sessions_root.clone()), + metadata_store: SessionMetadataStore::new(sessions_root), + json_store: JsonFileStore, + } + } + + pub async fn write_bundle( + &self, + bundle: &OfflineSessionBundle, + ) -> Result<(), OfflineSessionImportError> { + validate_bundle_shape(bundle)?; + self.metadata_store.save_metadata(&bundle.metadata).await?; + self.layout + .ensure_turns_dir(&bundle.metadata.session_id) + .await + .map_err(|error| { + OfflineSessionImportError::InvalidBundle(format!( + "failed to create Turns directory: {error}" + )) + })?; + for turn in &bundle.turns { + self.json_store + .write_atomic_strict( + &self.layout.turn_path(&turn.session_id, turn.turn_index), + &StoredDialogTurnFile::new(turn.clone()), + ) + .await?; + } + Ok(()) + } + + pub async fn load_bundle( + &self, + session_id: &str, + ) -> Result, OfflineSessionImportError> { + validate_session_id(session_id).map_err(OfflineSessionImportError::InvalidBundle)?; + let Some(stored_metadata) = self + .json_store + .read_optional::(&self.layout.metadata_path(session_id)) + .await? + else { + return Ok(None); + }; + if stored_metadata.schema_version > SESSION_STORAGE_SCHEMA_VERSION { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Session metadata schema {} is newer than supported schema {}", + stored_metadata.schema_version, SESSION_STORAGE_SCHEMA_VERSION + ))); + } + + let mut turns = Vec::new(); + for (file_index, path) in self + .layout + .list_indexed_turn_paths(session_id) + .await + .map_err(|error| { + OfflineSessionImportError::InvalidBundle(format!( + "failed to list persisted Turns: {error}" + )) + })? + { + let stored = self + .json_store + .read_optional::(&path) + .await? + .ok_or_else(|| { + OfflineSessionImportError::InvalidBundle(format!( + "persisted Turn disappeared while reading {}", + path.display() + )) + })?; + if stored.schema_version > SESSION_STORAGE_SCHEMA_VERSION { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Turn schema {} is newer than supported schema {}", + stored.schema_version, SESSION_STORAGE_SCHEMA_VERSION + ))); + } + if stored.turn.turn_index != file_index { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Turn file index {file_index} does not match payload index {}", + stored.turn.turn_index + ))); + } + turns.push(stored.turn); + } + let bundle = OfflineSessionBundle { + metadata: stored_metadata.metadata, + turns, + }; + validate_bundle_shape(&bundle)?; + Ok(Some(bundle)) + } + + pub async fn rebuild_index(&self) -> Result<(), OfflineSessionImportError> { + self.metadata_store.rebuild_index().await?; + Ok(()) + } + + pub fn sessions_root(&self) -> &Path { + self.layout.sessions_root() + } +} + +fn validate_bundle_shape(bundle: &OfflineSessionBundle) -> Result<(), OfflineSessionImportError> { + validate_session_id(&bundle.metadata.session_id) + .map_err(OfflineSessionImportError::InvalidBundle)?; + if bundle.metadata.turn_count != bundle.turns.len() { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Session {} declares {} Turns but contains {}", + bundle.metadata.session_id, + bundle.metadata.turn_count, + bundle.turns.len() + ))); + } + let mut indices = BTreeSet::new(); + let mut turn_ids = BTreeSet::new(); + for turn in &bundle.turns { + if turn.session_id != bundle.metadata.session_id { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Turn {} belongs to a different Session", + turn.turn_id + ))); + } + if !indices.insert(turn.turn_index) { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Session {} contains duplicate Turn index {}", + bundle.metadata.session_id, turn.turn_index + ))); + } + if !turn_ids.insert(turn.turn_id.as_str()) { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Session {} contains duplicate Turn id", + bundle.metadata.session_id + ))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[tokio::test] + async fn owner_writer_round_trips_a_legacy_bundle_in_the_current_format() { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1", + ); + let stored_metadata: StoredSessionMetadataFile = + serde_json::from_slice(&fs::read(fixture.join("metadata.json")).unwrap()).unwrap(); + let stored_turn: StoredDialogTurnFile = + serde_json::from_slice(&fs::read(fixture.join("turns/turn-0000.json")).unwrap()) + .unwrap(); + let bundle = OfflineSessionBundle { + metadata: stored_metadata.metadata, + turns: vec![stored_turn.turn], + }; + let temp_root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("E:/tmp")); + fs::create_dir_all(&temp_root).unwrap(); + let temp = tempfile::Builder::new() + .prefix("openbitfun-offline-session-") + .tempdir_in(temp_root) + .unwrap(); + let store = OfflineSessionImportStore::new(temp.path()); + + store.write_bundle(&bundle).await.unwrap(); + let reloaded = store.load_bundle("session-1").await.unwrap().unwrap(); + reloaded.validate().unwrap(); + assert_eq!(reloaded.metadata.session_id, "session-1"); + assert_eq!(reloaded.turns.len(), 1); + assert_eq!(reloaded.turns[0].turn_id, "turn-1"); + let stored: StoredSessionMetadataFile = + serde_json::from_slice(&fs::read(temp.path().join("session-1/metadata.json")).unwrap()) + .unwrap(); + assert_eq!(stored.schema_version, SESSION_STORAGE_SCHEMA_VERSION); + } +} diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index 32e5794a7d..9e74daf813 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -339,6 +339,22 @@ impl StoredSessionMetadataFile { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredDialogTurnFile { + pub schema_version: u32, + #[serde(flatten)] + pub turn: DialogTurnData, +} + +impl StoredDialogTurnFile { + pub fn new(turn: DialogTurnData) -> Self { + Self { + schema_version: SESSION_STORAGE_SCHEMA_VERSION, + turn, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredSessionIndexFile { pub schema_version: u32, diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index b734c1dc11..9f462d43ac 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -28,6 +28,10 @@ slices that are outside pure product logic but still platform-neutral. orchestration ports, LAN/ngrok provider helpers, IM bot provider clients, provider-private cursor caches, mobile-web relay upload, image-context adapter contracts, remote workspace helpers, and command/response assembly. +- The `remote-persistence` feature is the lightweight persisted-shape owner shared + by Remote Connect, remote SSH, and offline migration. Keep it free of network, + SSH transport, and runtime orchestration dependencies so owner readers and + writers can validate staged data without enabling those heavier families. - Remote workspace facts, session metadata, file projection DTOs, and workspace/projection host traits belong in `openbitfun-runtime-ports`. - Workspace-root source selection, persistence/workspace service reads, @@ -108,6 +112,7 @@ streamable HTTP stay independent. Representative stable entry points are: ```bash cargo check -p openbitfun-services-integrations --no-default-features +cargo test -p openbitfun-services-integrations --no-default-features --features remote-persistence --lib remote_persistence::tests:: cargo test -p openbitfun-services-integrations --no-default-features --features mcp --test mcp_contracts cargo test -p openbitfun-services-integrations --no-default-features --features mcp --test mcp_streamable_http_contracts cargo test -p openbitfun-services-integrations --no-default-features --features remote-ssh --test remote_ssh_contracts remote_ssh_disabled_contracts:: diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 372f5efef5..b38dbdaf51 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -171,7 +171,15 @@ mcp = [ "url", "process-tree", ] +miniapp-storage = [ + "dep:openbitfun-product-domains", + "openbitfun-product-domains/miniapp", + "tokio/fs", + "tokio/time", + "uuid", +] miniapp-runtime = [ + "miniapp-storage", "base64", "openbitfun-product-domains/miniapp", "openbitfun-services-core/process-runtime", @@ -248,6 +256,7 @@ hook-import = [ "uuid", ] remote-connect = [ + "remote-persistence", "anyhow", "aes", "aes-gcm", @@ -297,6 +306,7 @@ remote-connect = [ "tokio-tungstenite?/rustls-tls-native-roots", "urlencoding", "uuid", + "windows", "which", "x25519-dalek", ] @@ -325,6 +335,7 @@ remote-ssh = [ ] remote-ssh-concrete = [ "remote-ssh", + "remote-persistence", "aes-gcm", "anyhow", "async-trait", @@ -347,6 +358,17 @@ remote-ssh-concrete = [ "terminal-core", "thiserror", "uuid", + "windows", +] +remote-persistence = [ + "aes-gcm", + "anyhow", + "base64", + "hostname", + "openbitfun-services-core/product-identity", + "rand", + "sha2", + "windows", ] review-platform = [ "async-trait", diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index 7ca6e7e521..2e3e7a0686 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -63,7 +63,7 @@ pub mod mcp; #[cfg(feature = "models-dev")] pub mod models_dev; -#[cfg(feature = "miniapp-runtime")] +#[cfg(any(feature = "miniapp-runtime", feature = "miniapp-storage"))] pub mod miniapp; #[cfg(feature = "miniapp-market")] @@ -78,6 +78,9 @@ mod repository_trust; #[cfg(feature = "remote-connect")] pub mod remote_connect; +#[cfg(feature = "remote-persistence")] +pub mod remote_persistence; + #[cfg(all(test, feature = "remote-connect"))] mod feature_contract_tests { #[test] diff --git a/src/crates/services/services-integrations/src/miniapp/mod.rs b/src/crates/services/services-integrations/src/miniapp/mod.rs index 0b72d5f3ce..fe88f7cc1d 100644 --- a/src/crates/services/services-integrations/src/miniapp/mod.rs +++ b/src/crates/services/services-integrations/src/miniapp/mod.rs @@ -1,7 +1,11 @@ //! MiniApp concrete integration services. +#[cfg(feature = "miniapp-runtime")] pub mod builtin_io; +#[cfg(feature = "miniapp-runtime")] pub mod host_dispatch; 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/storage.rs b/src/crates/services/services-integrations/src/miniapp/storage.rs index c625f3010a..3df61e62a5 100644 --- a/src/crates/services/services-integrations/src/miniapp/storage.rs +++ b/src/crates/services/services-integrations/src/miniapp/storage.rs @@ -283,6 +283,91 @@ impl MiniAppStorage { Ok(()) } + /// Write a validated import bundle from an offline owner such as the data migrator. + /// + /// Callers must provide an isolated destination root. Product runtime code should + /// continue using the async port; this synchronous entrypoint exists so an offline + /// process does not need to construct the normal MiniApp runtime. + pub fn write_import_bundle_offline( + &self, + request: MiniAppImportBundleWriteRequest, + ) -> MiniAppStorageResult<()> { + let import_layout = MiniAppImportLayout::new(&request.source_path); + Self::validate_import_layout(&request.source_path, &import_layout)?; + let destination = self.layout(&request.app_id); + std::fs::create_dir_all(destination.source_dir()).map_err(|error| { + MiniAppStorageError::io(format!( + "Failed to create offline import directory: {error}" + )) + })?; + std::fs::write(destination.meta_path(), request.meta_json).map_err(|error| { + MiniAppStorageError::io(format!("Failed to write meta.json: {error}")) + })?; + for name in REQUIRED_SOURCE_FILES { + std::fs::copy( + import_layout.source_file_path(name), + destination.source_file_path(name), + ) + .map_err(|error| { + MiniAppStorageError::io(format!("Failed to copy source/{name}: {error}")) + })?; + } + let esm_source = import_layout.esm_dependencies_path(); + if esm_source.exists() { + std::fs::copy(&esm_source, destination.source_file_path(ESM_DEPS_JSON)).map_err( + |error| { + MiniAppStorageError::io(format!( + "Failed to copy esm_dependencies.json: {error}" + )) + }, + )?; + } else { + std::fs::write( + destination.source_file_path(ESM_DEPS_JSON), + request.esm_dependencies_json, + ) + .map_err(|error| { + MiniAppStorageError::io(format!("Failed to write esm_dependencies.json: {error}")) + })?; + } + let package_source = import_layout.package_json_path(); + if package_source.exists() { + std::fs::copy(package_source, destination.package_json_path()).map_err(|error| { + MiniAppStorageError::io(format!("Failed to copy package.json: {error}")) + })?; + } else { + std::fs::write(destination.package_json_path(), request.package_json).map_err( + |error| MiniAppStorageError::io(format!("Failed to write package.json: {error}")), + )?; + } + let storage_source = import_layout.storage_json_path(); + if storage_source.exists() { + std::fs::copy(storage_source, destination.storage_path()).map_err(|error| { + MiniAppStorageError::io(format!("Failed to copy storage.json: {error}")) + })?; + } else { + std::fs::write(destination.storage_path(), request.storage_json).map_err(|error| { + MiniAppStorageError::io(format!("Failed to write storage.json: {error}")) + })?; + } + std::fs::write(destination.compiled_path(), request.compiled_html).map_err(|error| { + MiniAppStorageError::io(format!("Failed to write compiled.html: {error}")) + }) + } + + /// Validate and read an import bundle without starting the MiniApp runtime. + pub fn read_import_meta_json_offline( + &self, + source_path: impl AsRef, + ) -> MiniAppStorageResult { + let source_path = source_path.as_ref(); + let import_layout = MiniAppImportLayout::new(source_path); + Self::validate_import_layout(source_path, &import_layout)?; + std::fs::read_to_string(import_layout.meta_path()).map_err(|error| { + MiniAppStorageError::io(format!("Failed to read offline import meta.json: {error}")) + }) + } + /// Ensure app directory and source subdir exist. pub async fn ensure_app_dir(&self, app_id: &str) -> MiniAppStorageResult<()> { let dir = self.app_dir(app_id); diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index f36e0f652c..c8abb5ab9c 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -53,7 +53,7 @@ pub use openbitfun_runtime_ports::{ RemoteWorkspaceFileContent, RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, RemoteWorkspacePort, RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, }; -use openbitfun_services_core::product_identity::{hidden_data_directory, product_id}; +use openbitfun_services_core::product_identity::hidden_data_directory; pub use page_upload::{ create_page_open_link_on_relay, delete_page_from_relay, delete_page_version_on_relay, deploy_page_version_on_relay, join_relay_url, list_page_versions_from_relay, diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs b/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs index b6c559d431..0f6438094b 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs @@ -511,9 +511,12 @@ fn load_bot_persistence_unlocked() -> BotPersistenceData { let Some(path) = bot_persistence_path() else { return BotPersistenceData::default(); }; - match std::fs::read_to_string(&path) { - Ok(data) => serde_json::from_str(&data).unwrap_or_default(), - Err(_) => { + match crate::remote_persistence::read_bot_persistence(&path) { + Ok(Some(data)) => serde_json::to_value(data) + .ok() + .and_then(|value| serde_json::from_value(value).ok()) + .unwrap_or_default(), + Ok(None) | Err(_) => { // A backup without the canonical file means the process stopped // during the Windows replace dance. Fail closed instead of // restoring a pre-clear account context. @@ -600,7 +603,12 @@ fn save_bot_persistence_unlocked(data: &BotPersistenceData) { return; }; if let Ok(json) = serde_json::to_string_pretty(data) { - if let Err(e) = write_bot_persistence_atomic(&path, json.as_bytes()) { + let owner_valid = + serde_json::from_str::(&json) + .is_ok_and(|value| value.validate().is_ok()); + if !owner_valid { + log::error!("Failed to save bot persistence: owner validation failed"); + } else if let Err(e) = write_bot_persistence_atomic(&path, json.as_bytes()) { log::error!("Failed to save bot persistence: {e}"); } } diff --git a/src/crates/services/services-integrations/src/remote_connect/device.rs b/src/crates/services/services-integrations/src/remote_connect/device.rs index 2798e2ca7a..0964062e6a 100644 --- a/src/crates/services/services-integrations/src/remote_connect/device.rs +++ b/src/crates/services/services-integrations/src/remote_connect/device.rs @@ -12,12 +12,7 @@ use anyhow::{anyhow, Context, Result}; use sha2::{Digest, Sha256}; /// Represents a device's identity used for pairing and account routing. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct DeviceIdentity { - pub device_id: String, - pub device_name: String, - pub mac_address: String, -} +pub use crate::remote_persistence::DeviceIdentityRecord as DeviceIdentity; static CACHED_IDENTITY: Mutex> = Mutex::new(None); @@ -122,29 +117,15 @@ fn identity_file_path() -> Result { fn load_persisted() -> Result> { let path = identity_file_path()?; - let json = match std::fs::read_to_string(&path) { - Ok(data) => data, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => { - return Err(anyhow!("read device identity: {e}").context(path.display().to_string())) - } - }; - let identity: DeviceIdentity = - serde_json::from_str(&json).context("parse device identity file")?; - if !is_valid_device_id(&identity.device_id) { - return Err(anyhow!("persisted device_id is invalid")); - } - Ok(Some(identity)) + crate::remote_persistence::read_device_identity(&path) + .context("read device identity") + .map_err(|error| error.context(path.display().to_string())) } fn save_persisted(identity: &DeviceIdentity) -> Result<()> { let path = identity_file_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).context("create device identity dir")?; - } - let json = serde_json::to_string_pretty(identity).context("serialize device identity")?; - std::fs::write(&path, json).context("write device identity file")?; - Ok(()) + crate::remote_persistence::write_device_identity(&path, identity) + .context("write device identity file") } fn cache_identity(identity: DeviceIdentity) { diff --git a/src/crates/services/services-integrations/src/remote_connect/session_store.rs b/src/crates/services/services-integrations/src/remote_connect/session_store.rs index d4d3b0fa02..c76ca587e7 100644 --- a/src/crates/services/services-integrations/src/remote_connect/session_store.rs +++ b/src/crates/services/services-integrations/src/remote_connect/session_store.rs @@ -12,19 +12,9 @@ //! Format: base64(nonce || ciphertext) where the plaintext is a JSON //! payload `{ token, user_id, master_key_b64, relay_url }`. +use anyhow::{anyhow, Result}; use std::path::PathBuf; use std::sync::{OnceLock, RwLock}; -use std::{fs::OpenOptions, io::Write}; - -use aes_gcm::aead::{Aead, KeyInit, OsRng}; -use aes_gcm::{Aes256Gcm, Nonce}; -use anyhow::{anyhow, Context, Result}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use rand::RngCore; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -const NONCE_SIZE: usize = 12; fn session_store_directory_override() -> &'static RwLock> { static OVERRIDE: OnceLock>> = OnceLock::new(); @@ -52,216 +42,11 @@ fn session_store_directory() -> Result { super::product_home_dir().ok_or_else(|| anyhow!("cannot determine OpenBitFun home directory")) } -/// The on-disk JSON payload (plaintext before encryption). -#[derive(Serialize, Deserialize)] -struct SessionPayload { - token: String, - user_id: String, - /// Base64-encoded 32-byte master key. - master_key_b64: String, - relay_url: String, - /// Account-bound device id used when the session token was issued. - /// Optional for backward compatibility with sessions saved before this field. - #[serde(default, skip_serializing_if = "Option::is_none")] - device_id: Option, -} - /// Resolve the persistent session file path. fn session_file_path() -> Result { Ok(session_store_directory()?.join("account_session.enc")) } -fn session_key_file_path() -> Result { - Ok(session_store_directory()?.join("account_session.key")) -} - -/// Atomically replace a secret-bearing file and restrict it to the current -/// OS user where Unix permission bits are available. The parent is also made -/// private so a permissive process umask cannot expose account material. -fn write_private_file(path: &std::path::Path, contents: &[u8]) -> Result<()> { - let parent = path - .parent() - .ok_or_else(|| anyhow!("secret file has no parent directory"))?; - std::fs::create_dir_all(parent).context("create private data directory")?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) - .context("restrict private data directory permissions")?; - } - - let mut random = [0u8; 8]; - OsRng.fill_bytes(&mut random); - let suffix = random - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| anyhow!("secret file has an invalid name"))?; - let temp_path = parent.join(format!(".{file_name}.{suffix}.tmp")); - - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let write_result = (|| -> Result<()> { - let mut file = options - .open(&temp_path) - .context("create temporary secret file")?; - file.write_all(contents) - .context("write temporary secret file")?; - file.sync_all().context("flush temporary secret file")?; - drop(file); - - #[cfg(windows)] - if path.exists() { - std::fs::remove_file(path).context("replace existing secret file")?; - } - std::fs::rename(&temp_path, path).context("install private secret file")?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) - .context("restrict secret file permissions")?; - } - Ok(()) - })(); - if write_result.is_err() { - let _ = std::fs::remove_file(&temp_path); - } - write_result -} - -/// Derive a machine-bound AES-256 key from stable machine identifiers. -/// -/// Combines hostname, OS username, OS type, and platform constant into -/// SHA-256 to produce a 32-byte key. The file is useless on a different -/// machine (different hostname / username combination). -fn derive_machine_key(local_secret: &[u8; 32]) -> [u8; 32] { - let mut hasher = Sha256::new(); - - // Hostname / machine name - if let Some(hostname) = hostname_string() { - hasher.update(hostname.as_bytes()); - } - hasher.update(b"|"); - - // OS username - if let Some(username) = username_string() { - hasher.update(username.as_bytes()); - } - hasher.update(b"|"); - - // OS type (linux / macos / windows) - hasher.update(std::env::consts::OS.as_bytes()); - hasher.update(b"|"); - - // Product-specific domain separation prevents credentials from crossing - // product data namespaces even when they share the same machine account. - hasher.update(super::product_id().as_bytes()); - hasher.update(b"::session_store::v1|"); - hasher.update(local_secret); - let result = hasher.finalize(); - let mut key = [0u8; 32]; - key.copy_from_slice(&result); - key -} - -fn load_or_create_local_session_secret() -> Result<[u8; 32]> { - let path = session_key_file_path()?; - match std::fs::read(&path) { - Ok(bytes) => { - return bytes - .try_into() - .map_err(|_| anyhow!("local account session key has an invalid length")); - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(anyhow!("read local account session key: {error}")), - } - - let mut secret = [0u8; 32]; - OsRng.fill_bytes(&mut secret); - let parent = path - .parent() - .ok_or_else(|| anyhow!("session key has no parent directory"))?; - std::fs::create_dir_all(parent).context("create session key directory")?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) - .context("restrict session key directory permissions")?; - } - - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - match options.open(&path) { - Ok(mut file) => { - file.write_all(&secret) - .context("write local account session key")?; - file.sync_all().context("flush local account session key")?; - Ok(secret) - } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - let bytes = std::fs::read(&path).context("read concurrently created session key")?; - bytes - .try_into() - .map_err(|_| anyhow!("local account session key has an invalid length")) - } - Err(error) => Err(anyhow!("create local account session key: {error}")), - } -} - -/// Best-effort hostname retrieval (cross-platform). -fn hostname_string() -> Option { - // `hostname::get()` from the `hostname` crate would be cleaner, but - // to avoid adding a new dependency we use environment / std methods. - // - // On Unix: read /etc/hostname or call `gethostname` via libc. - // On Windows: `%COMPUTERNAME%`. - if cfg!(target_os = "windows") { - std::env::var("COMPUTERNAME").ok() - } else { - // Try `hostname` command as a portable fallback. - std::process::Command::new("hostname") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - .or_else(|| { - std::fs::read_to_string("/etc/hostname") - .ok() - .map(|s| s.trim().to_string()) - }) - } -} - -/// Best-effort current username retrieval (cross-platform). -fn username_string() -> Option { - if cfg!(target_os = "windows") { - std::env::var("USERNAME") - .or_else(|_| std::env::var("USER")) - .ok() - } else { - std::env::var("USER").ok().or_else(|| { - std::process::Command::new("whoami") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - }) - } -} - // ── Public API ────────────────────────────────────────────────────────── /// Persist the session (token, master_key, user_id, relay_url) to disk, @@ -283,41 +68,21 @@ pub fn save_session_with_device( relay_url: &str, device_id: Option<&str>, ) -> Result<()> { - let payload = SessionPayload { + let payload = crate::remote_persistence::AccountSessionRecord { token: token.to_string(), user_id: user_id.to_string(), - master_key_b64: BASE64.encode(master_key), + master_key: *master_key, relay_url: relay_url.to_string(), device_id: device_id .map(str::trim) .filter(|id| !id.is_empty()) .map(str::to_string), }; - let json = serde_json::to_string(&payload).context("serialize session payload")?; - - let local_secret = load_or_create_local_session_secret()?; - let key = derive_machine_key(&local_secret); - let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow!("cipher init: {e}"))?; - - let mut nonce_bytes = [0u8; NONCE_SIZE]; - OsRng.fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); - - let ciphertext = cipher - .encrypt(nonce, json.as_bytes()) - .map_err(|e| anyhow!("encrypt session: {e}"))?; - - // Pack: nonce (12 bytes) || ciphertext, then base64-encode the whole thing. - let mut packed = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); - packed.extend_from_slice(&nonce_bytes); - packed.extend_from_slice(&ciphertext); - let encoded = BASE64.encode(&packed); - - let path = session_file_path()?; - write_private_file(&path, encoded.as_bytes()).context("write session file")?; - - log::debug!("Session persisted to {:?}", path); - Ok(()) + crate::remote_persistence::write_current_account_session( + &session_store_directory()?, + &crate::remote_persistence::MachineBinding::current(), + &payload, + ) } /// Loaded account session fields from disk. @@ -338,52 +103,14 @@ pub fn load_session() -> Result> { /// Load and decrypt the session, including optional account-bound `device_id`. pub fn load_session_detailed() -> Result> { - let path = session_file_path()?; - let encoded = match std::fs::read_to_string(&path) { - Ok(data) => data, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => { - return Err( - anyhow!("read session file: {e}").context(path.to_string_lossy().to_string()) - ) - } - }; - - let packed = BASE64 - .decode(encoded.trim()) - .map_err(|e| anyhow!("base64 decode session: {e}"))?; - if packed.len() < NONCE_SIZE { - return Err(anyhow!("session file too short")); - } - - let (nonce_bytes, ciphertext) = packed.split_at(NONCE_SIZE); - let nonce_arr: [u8; NONCE_SIZE] = nonce_bytes - .try_into() - .map_err(|_| anyhow!("nonce conversion"))?; - - let local_secret = load_or_create_local_session_secret()?; - let key = derive_machine_key(&local_secret); - let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow!("cipher init: {e}"))?; - let plaintext = cipher - .decrypt(Nonce::from_slice(&nonce_arr), ciphertext) - .map_err(|e| anyhow!("decrypt session: {e}"))?; - - let payload: SessionPayload = - serde_json::from_slice(&plaintext).context("deserialize session payload")?; - - let master_key_bytes = BASE64 - .decode(&payload.master_key_b64) - .map_err(|e| anyhow!("decode master key: {e}"))?; - let mut master_key = [0u8; 32]; - if master_key_bytes.len() != 32 { - return Err(anyhow!("invalid master key length")); - } - master_key.copy_from_slice(&master_key_bytes); - - Ok(Some(LoadedSession { + Ok(crate::remote_persistence::read_current_account_session( + &session_store_directory()?, + &crate::remote_persistence::MachineBinding::current(), + )? + .map(|payload| LoadedSession { token: payload.token, user_id: payload.user_id, - master_key, + master_key: payload.master_key, relay_url: payload.relay_url, device_id: payload.device_id, })) @@ -404,11 +131,7 @@ fn credential_hint_path() -> Result { } /// Non-secret login pre-fill (never stores password or master key). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccountHint { - pub username: String, - pub relay_url: String, -} +pub use crate::remote_persistence::AccountHintRecord as AccountHint; /// Persist username + relay URL for the next login form. pub fn save_credential_hint(username: &str, relay_url: &str) { @@ -416,22 +139,16 @@ pub fn save_credential_hint(username: &str, relay_url: &str) { username: username.to_string(), relay_url: relay_url.to_string(), }; - let Ok(path) = credential_hint_path() else { - return; - }; - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(json) = serde_json::to_string(&hint) { - let _ = write_private_file(&path, json.as_bytes()); + if let Ok(path) = credential_hint_path() { + let _ = crate::remote_persistence::write_account_hint(&path, &hint); } } /// Load the persisted credential hint, if any. pub fn load_credential_hint() -> Option { - let path = credential_hint_path().ok()?; - let json = std::fs::read_to_string(&path).ok()?; - serde_json::from_str(&json).ok() + crate::remote_persistence::read_account_hint(&credential_hint_path().ok()?) + .ok() + .flatten() } /// Clear the credential hint (called on logout). @@ -452,8 +169,8 @@ mod tests { let private_dir = root.path().join("private"); let path = private_dir.join("session.enc"); - write_private_file(&path, b"first").unwrap(); - write_private_file(&path, b"second").unwrap(); + crate::remote_persistence::write_private_bytes(&path, b"first").unwrap(); + crate::remote_persistence::write_private_bytes(&path, b"second").unwrap(); assert_eq!(std::fs::read(&path).unwrap(), b"second"); assert_eq!( diff --git a/src/crates/services/services-integrations/src/remote_connect/sync_state.rs b/src/crates/services/services-integrations/src/remote_connect/sync_state.rs index d2fdbc17d7..5e79cce555 100644 --- a/src/crates/services/services-integrations/src/remote_connect/sync_state.rs +++ b/src/crates/services/services-integrations/src/remote_connect/sync_state.rs @@ -10,35 +10,19 @@ //! backup loop and the settings sync engine are independent writers, so a //! shared read-modify-write file could drop one writer's update. -use std::collections::HashMap; use std::path::PathBuf; use anyhow::{anyhow, Result}; -use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; /// On-disk sync progress for one account. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct AccountSyncState { - /// Highest relay session `version` successfully processed by a pull. - #[serde(default)] - pub last_session_since: i64, - /// Last successfully uploaded content hash per session_id. - #[serde(default)] - pub uploaded_hashes: HashMap, -} +pub use crate::remote_persistence::AccountSyncStateRecord as AccountSyncState; /// Settings sync progress for one account: the cloud settings blob version /// this device last uploaded or applied, plus the content hash of that blob. /// Lets the periodic pull skip unchanged blobs across restarts and the push /// path skip unchanged content. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SettingsCursor { - #[serde(default)] - pub version: i64, - #[serde(default)] - pub hash: String, -} +pub use crate::remote_persistence::SettingsCursorRecord as SettingsCursor; /// SHA-256 hex digest of session bundle plaintext (stable skip key). pub fn content_hash(plaintext: &str) -> String { @@ -79,22 +63,16 @@ pub fn load(user_id: &str) -> AccountSyncState { Ok(p) => p, Err(_) => return AccountSyncState::default(), }; - match std::fs::read_to_string(&path) { - Ok(raw) => serde_json::from_str(&raw).unwrap_or_default(), - Err(_) => AccountSyncState::default(), - } + crate::remote_persistence::read_account_sync_state(&path) + .ok() + .flatten() + .unwrap_or_default() } /// Persist sync state for `user_id`. pub fn save(user_id: &str, state: &AccountSyncState) -> Result<()> { - let dir = sync_dir()?; - std::fs::create_dir_all(&dir)?; let path = sync_state_path(user_id)?; - let raw = serde_json::to_string_pretty(state)?; - let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, raw)?; - std::fs::rename(&tmp, &path)?; - Ok(()) + crate::remote_persistence::write_account_sync_state(&path, state) } /// Load the settings cursor for `user_id`, defaulting when missing/corrupt. @@ -103,22 +81,16 @@ pub fn load_settings_cursor(user_id: &str) -> SettingsCursor { Ok(p) => p, Err(_) => return SettingsCursor::default(), }; - match std::fs::read_to_string(&path) { - Ok(raw) => serde_json::from_str(&raw).unwrap_or_default(), - Err(_) => SettingsCursor::default(), - } + crate::remote_persistence::read_settings_cursor(&path) + .ok() + .flatten() + .unwrap_or_default() } /// Persist the settings cursor for `user_id`. pub fn save_settings_cursor(user_id: &str, cursor: &SettingsCursor) -> Result<()> { - let dir = sync_dir()?; - std::fs::create_dir_all(&dir)?; let path = settings_cursor_path(user_id)?; - let raw = serde_json::to_string_pretty(cursor)?; - let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, raw)?; - std::fs::rename(&tmp, &path)?; - Ok(()) + crate::remote_persistence::write_settings_cursor(&path, cursor) } impl AccountSyncState { diff --git a/src/crates/services/services-integrations/src/remote_persistence.rs b/src/crates/services/services-integrations/src/remote_persistence.rs new file mode 100644 index 0000000000..7e48fad32f --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_persistence.rs @@ -0,0 +1,1239 @@ +//! Path-explicit persistence owners shared by Remote Connect, Remote SSH, and +//! the offline retired-product migrator. +//! +//! Runtime loaders may intentionally recover from missing or corrupt files by +//! returning defaults. Importers must not do that: every reader in this module +//! is strict, bounded by its caller, and never logs persisted content. + +use aes_gcm::aead::{Aead, KeyInit, OsRng}; +use aes_gcm::{Aes256Gcm, Nonce}; +use anyhow::{anyhow, bail, Context, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use rand::RngCore; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Deserializer, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap}; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; + +const NONCE_SIZE: usize = 12; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeviceIdentityRecord { + pub device_id: String, + pub device_name: String, + pub mac_address: String, +} + +impl DeviceIdentityRecord { + pub fn validate(&self) -> Result<()> { + if self.device_id.len() != 32 + || !self + .device_id + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + bail!("device identity has an invalid stable id"); + } + if self.device_name.trim().is_empty() || self.mac_address.trim().is_empty() { + bail!("device identity is missing display metadata"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountHintRecord { + pub username: String, + pub relay_url: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountSyncStateRecord { + #[serde(default)] + pub last_session_since: i64, + #[serde(default)] + pub uploaded_hashes: HashMap, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SettingsCursorRecord { + #[serde(default)] + pub version: i64, + #[serde(default)] + pub hash: String, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct AccountSessionRecord { + pub token: String, + pub user_id: String, + pub master_key: [u8; 32], + pub relay_url: String, + pub device_id: Option, +} + +impl std::fmt::Debug for AccountSessionRecord { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AccountSessionRecord") + .field("token", &"[REDACTED]") + .field("user_id", &self.user_id) + .field("master_key", &"[REDACTED]") + .field("relay_url", &self.relay_url) + .field("device_id", &self.device_id) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MachineBinding { + pub hostname: String, + pub username: String, + pub os: String, +} + +impl MachineBinding { + pub fn current() -> Self { + let hostname = hostname::get() + .ok() + .and_then(|value| value.into_string().ok()) + .unwrap_or_default(); + let username = std::env::var("USERNAME") + .or_else(|_| std::env::var("USER")) + .unwrap_or_default(); + Self { + hostname, + username, + os: std::env::consts::OS.to_string(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LegacyAccountSessionKeyDomains<'a> { + pub v1: &'a [u8], + pub v2: &'a [u8], +} + +#[derive(Serialize, Deserialize)] +struct AccountSessionPayload { + token: String, + user_id: String, + master_key_b64: String, + relay_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + device_id: Option, +} + +pub fn read_device_identity(path: &Path) -> Result> { + let value: Option = read_optional_json(path)?; + if let Some(value) = &value { + value.validate()?; + } + Ok(value) +} + +pub fn write_device_identity(path: &Path, value: &DeviceIdentityRecord) -> Result<()> { + value.validate()?; + write_json_atomic(path, value, false) +} + +pub fn read_account_hint(path: &Path) -> Result> { + read_optional_json(path) +} + +pub fn write_account_hint(path: &Path, value: &AccountHintRecord) -> Result<()> { + write_json_atomic(path, value, true) +} + +pub fn read_account_sync_state(path: &Path) -> Result> { + read_optional_json(path) +} + +pub fn write_account_sync_state(path: &Path, value: &AccountSyncStateRecord) -> Result<()> { + write_json_atomic(path, value, false) +} + +pub fn read_settings_cursor(path: &Path) -> Result> { + read_optional_json(path) +} + +pub fn write_settings_cursor(path: &Path, value: &SettingsCursorRecord) -> Result<()> { + write_json_atomic(path, value, false) +} + +pub fn read_legacy_account_session( + directory: &Path, + binding: &MachineBinding, + domains: LegacyAccountSessionKeyDomains<'_>, +) -> Result> { + if domains.v1.is_empty() || domains.v2.is_empty() { + bail!("legacy account session key domains must not be empty"); + } + let encrypted_path = directory.join("account_session.enc"); + let Some(packed) = read_encrypted_blob(&encrypted_path)? else { + return Ok(None); + }; + let local_secret = match std::fs::read(directory.join("account_session.key")) { + Ok(bytes) => Some( + bytes + .try_into() + .map_err(|_| anyhow!("legacy account session key has an invalid length"))?, + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error).context("read legacy account session key"), + }; + let (nonce, ciphertext) = split_ciphertext(&packed)?; + let mut plaintext = None; + if let Some(secret) = local_secret { + let key = derive_legacy_v2_key(binding, &secret, domains); + plaintext = decrypt(&key, nonce, ciphertext).ok(); + } + if plaintext.is_none() { + let key = derive_legacy_v1_key(binding, domains.v1); + plaintext = decrypt(&key, nonce, ciphertext).ok(); + } + let plaintext = + plaintext.ok_or_else(|| anyhow!("legacy account session cannot be decrypted"))?; + decode_account_session_payload(&plaintext) + .context("validate legacy account session") + .map(Some) +} + +pub fn read_current_account_session( + directory: &Path, + binding: &MachineBinding, +) -> Result> { + let Some(packed) = read_encrypted_blob(&directory.join("account_session.enc"))? else { + return Ok(None); + }; + let secret: [u8; 32] = std::fs::read(directory.join("account_session.key")) + .context("read current account session key")? + .try_into() + .map_err(|_| anyhow!("current account session key has an invalid length"))?; + let (nonce, ciphertext) = split_ciphertext(&packed)?; + let key = derive_current_key(binding, &secret); + let plaintext = decrypt(&key, nonce, ciphertext) + .map_err(|_| anyhow!("current account session cannot be decrypted"))?; + decode_account_session_payload(&plaintext) + .context("validate current account session") + .map(Some) +} + +pub fn write_current_account_session( + directory: &Path, + binding: &MachineBinding, + session: &AccountSessionRecord, +) -> Result<()> { + validate_account_session(session)?; + std::fs::create_dir_all(directory).context("create account session directory")?; + let key_path = directory.join("account_session.key"); + let secret = match std::fs::read(&key_path) { + Ok(bytes) => bytes + .try_into() + .map_err(|_| anyhow!("current account session key has an invalid length"))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let mut value = [0u8; 32]; + OsRng.fill_bytes(&mut value); + write_atomic(&key_path, &value, true)?; + value + } + Err(error) => return Err(error).context("read current account session key"), + }; + let payload = AccountSessionPayload { + token: session.token.clone(), + user_id: session.user_id.clone(), + master_key_b64: BASE64.encode(session.master_key), + relay_url: session.relay_url.clone(), + device_id: session.device_id.clone(), + }; + let plaintext = serde_json::to_vec(&payload).context("serialize account session payload")?; + let key = derive_current_key(binding, &secret); + let mut nonce = [0u8; NONCE_SIZE]; + OsRng.fill_bytes(&mut nonce); + let ciphertext = Aes256Gcm::new_from_slice(&key) + .map_err(|_| anyhow!("initialize current account session cipher"))? + .encrypt(Nonce::from_slice(&nonce), plaintext.as_slice()) + .map_err(|_| anyhow!("encrypt current account session"))?; + let mut packed = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); + packed.extend_from_slice(&nonce); + packed.extend_from_slice(&ciphertext); + write_atomic( + &directory.join("account_session.enc"), + BASE64.encode(packed).as_bytes(), + true, + ) +} + +fn validate_account_session(session: &AccountSessionRecord) -> Result<()> { + if session.token.trim().is_empty() + || session.user_id.trim().is_empty() + || session.relay_url.trim().is_empty() + { + bail!("account session is missing required identity fields"); + } + if session.device_id.as_deref().is_some_and(|device_id| { + device_id.len() != 32 || !device_id.chars().all(|value| value.is_ascii_hexdigit()) + }) { + bail!("account session has an invalid device id"); + } + Ok(()) +} + +fn decode_account_session_payload(bytes: &[u8]) -> Result { + let payload: AccountSessionPayload = + serde_json::from_slice(bytes).context("deserialize account session payload")?; + let master_key: [u8; 32] = BASE64 + .decode(payload.master_key_b64) + .context("decode account session master key")? + .try_into() + .map_err(|_| anyhow!("account session master key has an invalid length"))?; + let session = AccountSessionRecord { + token: payload.token, + user_id: payload.user_id, + master_key, + relay_url: payload.relay_url, + device_id: payload.device_id, + }; + validate_account_session(&session)?; + Ok(session) +} + +fn derive_legacy_v1_key(binding: &MachineBinding, domain: &[u8]) -> [u8; 32] { + derive_machine_domain_key(binding, domain, None) +} + +fn derive_legacy_v2_key( + binding: &MachineBinding, + local_secret: &[u8; 32], + domains: LegacyAccountSessionKeyDomains<'_>, +) -> [u8; 32] { + let legacy = derive_legacy_v1_key(binding, domains.v1); + let mut hasher = Sha256::new(); + hasher.update(legacy); + hasher.update(domains.v2); + hasher.update(local_secret); + hasher.finalize().into() +} + +fn derive_current_key(binding: &MachineBinding, local_secret: &[u8; 32]) -> [u8; 32] { + let product_id = openbitfun_services_core::product_identity::product_id(); + derive_machine_domain_key( + binding, + format!("{product_id}::session_store::v1|").as_bytes(), + Some(local_secret), + ) +} + +fn derive_machine_domain_key( + binding: &MachineBinding, + domain: &[u8], + local_secret: Option<&[u8; 32]>, +) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(binding.hostname.as_bytes()); + hasher.update(b"|"); + hasher.update(binding.username.as_bytes()); + hasher.update(b"|"); + hasher.update(binding.os.as_bytes()); + hasher.update(b"|"); + hasher.update(domain); + if let Some(secret) = local_secret { + hasher.update(secret); + } + hasher.finalize().into() +} + +fn read_encrypted_blob(path: &Path) -> Result>> { + let encoded = match std::fs::read_to_string(path) { + Ok(value) => value, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("read encrypted persistence file"), + }; + BASE64 + .decode(encoded.trim()) + .context("decode encrypted persistence file") + .map(Some) +} + +fn split_ciphertext(packed: &[u8]) -> Result<(&[u8], &[u8])> { + if packed.len() <= NONCE_SIZE { + bail!("encrypted persistence file is too short"); + } + Ok(packed.split_at(NONCE_SIZE)) +} + +fn decrypt(key: &[u8; 32], nonce: &[u8], ciphertext: &[u8]) -> Result> { + Aes256Gcm::new_from_slice(key) + .map_err(|_| anyhow!("initialize encrypted persistence cipher"))? + .decrypt(Nonce::from_slice(nonce), ciphertext) + .map_err(|_| anyhow!("decrypt encrypted persistence payload")) +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "bot_type", rename_all = "snake_case")] +pub enum BotConfigRecord { + Feishu { + app_id: String, + app_secret: String, + }, + Telegram { + bot_token: String, + }, + Weixin { + ilink_token: String, + base_url: String, + bot_account_id: String, + }, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum BotDisplayModeRecord { + #[serde(rename = "pro")] + Pro, + #[default] + #[serde(rename = "assistant")] + Assistant, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BotWorkspaceRefRecord { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + +fn deserialize_workspace_ref<'de, D>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum Raw { + Path(String), + Full(BotWorkspaceRefRecord), + } + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(Raw::Path(path)) if path.trim().is_empty() => Ok(None), + Some(Raw::Path(path)) => Ok(Some(BotWorkspaceRefRecord { + path, + remote_connection_id: None, + remote_ssh_host: None, + })), + Some(Raw::Full(value)) if value.path.trim().is_empty() => Ok(None), + Some(Raw::Full(value)) => Ok(Some(value)), + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BotChatStateRecord { + pub chat_id: String, + pub paired: bool, + #[serde( + default, + deserialize_with = "deserialize_workspace_ref", + skip_serializing_if = "Option::is_none" + )] + pub current_workspace: Option, + pub current_assistant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_assistant_name: Option, + pub current_session_id: Option, + #[serde(default)] + pub display_mode: BotDisplayModeRecord, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub account_remote_context: bool, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedBotConnectionRecord { + pub bot_type: String, + pub chat_id: String, + pub config: BotConfigRecord, + pub chat_state: BotChatStateRecord, + pub connected_at: i64, +} + +#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteConnectFormStateRecord { + pub custom_server_url: String, + pub telegram_bot_token: String, + pub feishu_app_id: String, + pub feishu_app_secret: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub weixin_ilink_token: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub weixin_base_url: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub weixin_bot_account_id: String, +} + +#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BotPersistenceRecord { + #[serde(default)] + pub connections: Vec, + #[serde(default)] + pub form_state: RemoteConnectFormStateRecord, + #[serde(default)] + pub verbose_mode: bool, +} + +impl std::fmt::Debug for BotPersistenceRecord { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BotPersistenceRecord") + .field("connection_count", &self.connections.len()) + .field("verbose_mode", &self.verbose_mode) + .field("credentials", &"[REDACTED]") + .finish() + } +} + +impl BotPersistenceRecord { + pub fn validate(&self) -> Result<()> { + let mut types = std::collections::BTreeSet::new(); + for connection in &self.connections { + if connection.bot_type.trim().is_empty() + || connection.chat_id != connection.chat_state.chat_id + || !types.insert(connection.bot_type.as_str()) + { + bail!("bot persistence contains an invalid or duplicate connection"); + } + let config_type = match connection.config { + BotConfigRecord::Feishu { .. } => "feishu", + BotConfigRecord::Telegram { .. } => "telegram", + BotConfigRecord::Weixin { .. } => "weixin", + }; + if connection.bot_type != config_type { + bail!("bot persistence connection type does not match its configuration"); + } + } + Ok(()) + } +} + +pub fn read_bot_persistence(path: &Path) -> Result> { + let value: Option = read_optional_json(path)?; + if let Some(value) = &value { + value.validate()?; + } + Ok(value) +} + +pub fn write_bot_persistence(path: &Path, value: &BotPersistenceRecord) -> Result<()> { + value.validate()?; + write_json_atomic(path, value, true) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ContainerAccessRecord { + Sshd, + DockerExec, + Auto, +} + +fn default_docker_path() -> String { + "docker".to_string() +} +fn default_container_shell() -> String { + "/bin/sh".to_string() +} +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContainerWorkspaceRecord { + pub name: String, + pub access: ContainerAccessRecord, + #[serde(default)] + pub local: bool, + #[serde(default = "default_docker_path")] + pub docker_path: String, + #[serde(default = "default_container_shell")] + pub shell: String, + #[serde(default)] + pub user: Option, + #[serde(default = "default_true")] + pub interactive: bool, +} + +fn default_connect_timeout_secs() -> u64 { + 30 +} +fn default_auth_timeout_secs() -> u64 { + 60 +} +fn default_auth_attempts() -> u8 { + 3 +} +fn default_connect_attempts() -> u8 { + 1 +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SshConnectionOptionsRecord { + #[serde(default = "default_connect_timeout_secs")] + pub connect_timeout_secs: u64, + #[serde(default = "default_auth_timeout_secs")] + pub auth_timeout_secs: u64, + #[serde(default = "default_auth_attempts")] + pub auth_attempts: u8, + #[serde(default = "default_connect_attempts")] + pub connect_attempts: u8, +} + +impl Default for SshConnectionOptionsRecord { + fn default() -> Self { + Self { + connect_timeout_secs: default_connect_timeout_secs(), + auth_timeout_secs: default_auth_timeout_secs(), + auth_attempts: default_auth_attempts(), + connect_attempts: default_connect_attempts(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum SavedAuthTypeRecord { + Password, + PrivateKey { + #[serde(rename = "keyPath")] + key_path: String, + #[serde(default, rename = "certificatePath")] + certificate_path: Option, + }, + Agent { + #[serde(default, rename = "keyFingerprint")] + key_fingerprint: Option, + #[serde(default, rename = "fallbackKeyPath")] + fallback_key_path: Option, + }, + KeyboardInteractive, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SavedConnectionRecord { + pub id: String, + pub name: String, + pub host: String, + pub port: u16, + pub username: String, + #[serde(rename = "authType")] + pub auth_type: SavedAuthTypeRecord, + #[serde(rename = "defaultWorkspace")] + pub default_workspace: Option, + #[serde(rename = "lastConnected")] + pub last_connected: Option, + #[serde(default)] + pub proxy_jump: Option, + #[serde(default)] + pub container: Option, + #[serde(default)] + pub options: SshConnectionOptionsRecord, +} + +impl SavedConnectionRecord { + pub fn validate(&self) -> Result<()> { + if self.id.trim().is_empty() || self.name.trim().is_empty() { + bail!("saved SSH connection is missing its identity"); + } + if self + .container + .as_ref() + .is_none_or(|container| !container.local) + && (self.host.trim().is_empty() || self.username.trim().is_empty() || self.port == 0) + { + bail!("saved SSH connection is missing host information"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteWorkspaceRecord { + #[serde(default)] + pub connection_id: String, + #[serde(default)] + pub remote_path: String, + #[serde(default)] + pub connection_name: String, + #[serde(default)] + pub ssh_host: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KnownHostRecord { + pub host: String, + pub port: u16, + pub key_type: String, + pub fingerprint: String, + pub public_key: String, +} + +pub fn read_saved_connections(path: &Path) -> Result>> { + let values: Option> = read_optional_json(path)?; + if let Some(values) = &values { + for value in values { + value.validate()?; + } + } + Ok(values) +} + +pub fn write_saved_connections(path: &Path, values: &[SavedConnectionRecord]) -> Result<()> { + for value in values { + value.validate()?; + } + write_json_atomic(path, values, false) +} + +pub fn read_current_remote_workspaces(path: &Path) -> Result>> { + let values: Option> = read_optional_json(path)?; + if let Some(values) = &values { + validate_remote_workspaces(values)?; + } + Ok(values) +} + +pub fn read_legacy_remote_workspaces(path: &Path) -> Result>> { + #[derive(Deserialize)] + #[serde(untagged)] + enum LegacyShape { + One(RemoteWorkspaceRecord), + Many(Vec), + } + let shape: Option = read_optional_json(path)?; + let values = shape.map(|shape| match shape { + LegacyShape::One(value) => vec![value], + LegacyShape::Many(values) => values, + }); + if let Some(values) = &values { + validate_remote_workspaces(values)?; + } + Ok(values) +} + +pub fn write_remote_workspaces(path: &Path, values: &[RemoteWorkspaceRecord]) -> Result<()> { + validate_remote_workspaces(values)?; + write_json_atomic(path, values, false) +} + +fn validate_remote_workspaces(values: &[RemoteWorkspaceRecord]) -> Result<()> { + for value in values { + if value.connection_id.trim().is_empty() || value.remote_path.trim().is_empty() { + bail!("remote workspace is missing its connection or POSIX path"); + } + if !value.remote_path.starts_with('/') { + bail!("remote workspace path is not an absolute POSIX path"); + } + } + Ok(()) +} + +pub fn read_known_hosts(path: &Path) -> Result>> { + let values: Option> = read_optional_json(path)?; + if let Some(values) = &values { + for value in values { + if value.host.trim().is_empty() + || value.port == 0 + || value.key_type.trim().is_empty() + || value.fingerprint.trim().is_empty() + || value.public_key.trim().is_empty() + { + bail!("known-host entry is incomplete"); + } + } + } + Ok(values) +} + +pub fn write_known_hosts(path: &Path, values: &[KnownHostRecord]) -> Result<()> { + let serialized = serde_json::to_vec_pretty(values).context("serialize known hosts")?; + let _: Vec = + serde_json::from_slice(&serialized).context("validate known hosts")?; + write_atomic(path, &serialized, false) +} + +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct SshVaultFile { + pub entries: BTreeMap, +} + +impl std::fmt::Debug for SshVaultFile { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SshVaultFile") + .field("entry_count", &self.entries.len()) + .finish() + } +} + +#[derive(Clone)] +pub struct SshVaultRecord { + pub key: [u8; 32], + pub file: SshVaultFile, +} + +impl std::fmt::Debug for SshVaultRecord { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SshVaultRecord") + .field("key", &"[REDACTED]") + .field("file", &self.file) + .finish() + } +} + +impl SshVaultRecord { + pub fn decrypt(&self, connection_id: &str) -> Result> { + self.file + .entries + .get(connection_id) + .map(|ciphertext| decrypt_vault_entry(&self.key, ciphertext)) + .transpose() + } + + pub fn store(&mut self, connection_id: String, plaintext: &str) -> Result<()> { + let ciphertext = encrypt_vault_entry(&self.key, plaintext)?; + self.file.entries.insert(connection_id, ciphertext); + Ok(()) + } + + pub fn remove(&mut self, connection_id: &str) { + self.file.entries.remove(connection_id); + } +} + +pub fn read_ssh_vault(directory: &Path) -> Result> { + let key_path = directory.join(".ssh_password_vault.key"); + let vault_path = directory.join("ssh_password_vault.json"); + let key_exists = key_path.exists(); + let vault_exists = vault_path.exists(); + if !key_exists && !vault_exists { + return Ok(None); + } + if key_exists != vault_exists { + bail!("SSH password vault key and ciphertext file must migrate as a pair"); + } + let key: [u8; 32] = std::fs::read(&key_path) + .context("read SSH password vault key")? + .try_into() + .map_err(|_| anyhow!("SSH password vault key has an invalid length"))?; + let file: SshVaultFile = read_required_json(&vault_path)?; + let record = SshVaultRecord { key, file }; + if let Some(connection_id) = record.file.entries.keys().next() { + let _ = record + .decrypt(connection_id) + .context("validate SSH password vault entry")?; + } + Ok(Some(record)) +} + +pub fn new_ssh_vault() -> SshVaultRecord { + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + SshVaultRecord { + key, + file: SshVaultFile::default(), + } +} + +pub fn write_ssh_vault(directory: &Path, value: &SshVaultRecord) -> Result<()> { + std::fs::create_dir_all(directory).context("create SSH persistence directory")?; + for connection_id in value.file.entries.keys() { + let _ = value + .decrypt(connection_id) + .context("validate SSH password vault before writing")?; + } + write_atomic(&directory.join(".ssh_password_vault.key"), &value.key, true)?; + write_json_atomic( + &directory.join("ssh_password_vault.json"), + &value.file, + true, + ) +} + +fn encrypt_vault_entry(key: &[u8; 32], plaintext: &str) -> Result { + let mut nonce = [0u8; NONCE_SIZE]; + OsRng.fill_bytes(&mut nonce); + let ciphertext = Aes256Gcm::new_from_slice(key) + .map_err(|_| anyhow!("initialize SSH password vault cipher"))? + .encrypt(Nonce::from_slice(&nonce), plaintext.as_bytes()) + .map_err(|_| anyhow!("encrypt SSH password vault entry"))?; + let mut packed = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); + packed.extend_from_slice(&nonce); + packed.extend_from_slice(&ciphertext); + Ok(BASE64.encode(packed)) +} + +fn decrypt_vault_entry(key: &[u8; 32], ciphertext: &str) -> Result { + let packed = BASE64 + .decode(ciphertext) + .context("decode SSH password vault entry")?; + let (nonce, ciphertext) = split_ciphertext(&packed)?; + let plaintext = + decrypt(key, nonce, ciphertext).map_err(|_| anyhow!("decrypt SSH password vault entry"))?; + String::from_utf8(plaintext).context("decode SSH password vault entry") +} + +pub fn read_context_tokens(path: &Path) -> Result>> { + let value: Option> = read_optional_json(path)?; + if value.as_ref().is_some_and(|tokens| { + tokens + .iter() + .any(|(peer, token)| peer.trim().is_empty() || token.trim().is_empty()) + }) { + bail!("Weixin context-token store contains an empty peer or token"); + } + Ok(value) +} + +pub fn write_context_tokens(path: &Path, value: &BTreeMap) -> Result<()> { + write_json_atomic(path, value, true) +} + +pub fn read_weixin_sync_buffer(path: &Path) -> Result> { + match std::fs::read_to_string(path) { + Ok(value) => Ok(Some(value.trim().to_string())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).context("read Weixin sync buffer"), + } +} + +pub fn write_weixin_sync_buffer(path: &Path, value: &str) -> Result<()> { + write_atomic(path, value.as_bytes(), true) +} + +pub fn write_private_bytes(path: &Path, value: &[u8]) -> Result<()> { + write_atomic(path, value, true) +} + +fn read_optional_json(path: &Path) -> Result> { + match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .with_context(|| format!("parse persisted JSON at {}", path.display())) + .map(Some), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => { + Err(error).with_context(|| format!("read persisted JSON at {}", path.display())) + } + } +} + +fn read_required_json(path: &Path) -> Result { + read_optional_json(path)?.ok_or_else(|| anyhow!("required persisted JSON is missing")) +} + +fn write_json_atomic(path: &Path, value: &T, private: bool) -> Result<()> { + let bytes = serde_json::to_vec_pretty(value).context("serialize persisted JSON")?; + write_atomic(path, &bytes, private) +} + +fn write_atomic(path: &Path, bytes: &[u8], private: bool) -> Result<()> { + #[cfg(not(unix))] + let _ = private; + let parent = path + .parent() + .ok_or_else(|| anyhow!("persistence path has no parent directory"))?; + std::fs::create_dir_all(parent).context("create persistence directory")?; + #[cfg(unix)] + if private { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) + .context("restrict persistence directory")?; + } + let mut nonce = [0u8; 8]; + OsRng.fill_bytes(&mut nonce); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("persistence path has an invalid file name"))?; + let suffix = nonce + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let temporary = parent.join(format!(".{name}.{suffix}.tmp")); + let result = (|| -> Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if private { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .context("create persistence temporary file")?; + file.write_all(bytes) + .context("write persistence temporary file")?; + file.sync_all() + .context("flush persistence temporary file")?; + drop(file); + replace_file(&temporary, path)?; + #[cfg(unix)] + if private { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .context("restrict persisted file")?; + } + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result +} + +#[cfg(not(windows))] +fn replace_file(source: &Path, target: &Path) -> Result<()> { + std::fs::rename(source, target).context("install persisted file") +} + +#[cfg(windows)] +fn replace_file(source: &Path, target: &Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source = source + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let target = target + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + unsafe { + MoveFileExW( + PCWSTR(source.as_ptr()), + PCWSTR(target.as_ptr()), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + .context("install persisted file") + } +} + +pub fn safe_account_file_component(value: &str) -> Option { + let normalized: String = value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '_' + } + }) + .collect(); + (!normalized.is_empty()).then_some(normalized) +} + +pub fn is_safe_weixin_account_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) +} + +pub fn account_sync_paths(directory: &Path) -> Result> { + let entries = match std::fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error).context("read account sync directory"), + }; + let mut paths = Vec::new(); + for entry in entries { + let entry = entry.context("read account sync entry")?; + let file_type = entry.file_type().context("read account sync entry type")?; + if file_type.is_symlink() || !file_type.is_file() { + bail!("account sync directory contains a non-regular entry"); + } + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) == Some("json") + && !path.to_string_lossy().ends_with(".tmp") + { + paths.push(path); + } + } + paths.sort(); + Ok(paths) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_v2_account_session_reencrypts_for_the_current_owner() { + let source = test_tempdir("legacy-session-source"); + let target = test_tempdir("legacy-session-target"); + let binding = MachineBinding { + hostname: "fixture-host".to_string(), + username: "fixture-user".to_string(), + os: "windows".to_string(), + }; + let domains = LegacyAccountSessionKeyDomains { + v1: b"retired-product::session_store::v1", + v2: b"|retired-product::session_store::v2|", + }; + let session = AccountSessionRecord { + token: "synthetic-token".to_string(), + user_id: "fixture-account".to_string(), + master_key: [0x42; 32], + relay_url: "https://relay.example.invalid".to_string(), + device_id: Some("0123456789abcdef0123456789abcdef".to_string()), + }; + let local_secret = [0x24; 32]; + std::fs::write(source.path().join("account_session.key"), local_secret).unwrap(); + write_legacy_session_fixture(source.path(), &binding, &session, &local_secret, domains); + + let decoded = read_legacy_account_session(source.path(), &binding, domains) + .unwrap() + .expect("legacy session"); + assert_eq!(decoded, session); + write_current_account_session(target.path(), &binding, &decoded).unwrap(); + assert_eq!( + read_current_account_session(target.path(), &binding).unwrap(), + Some(session) + ); + let ciphertext = + std::fs::read_to_string(target.path().join("account_session.enc")).unwrap(); + assert!(!ciphertext.contains("synthetic-token")); + } + + #[test] + fn bot_owner_upgrades_bare_workspace_and_redacts_debug_output() { + let root = test_tempdir("bot-persistence"); + let path = root.path().join("remote_connect_persistence.json"); + std::fs::write( + &path, + r#"{ + "connections":[{ + "bot_type":"telegram", + "chat_id":"chat-1", + "config":{"bot_type":"telegram","bot_token":"secret-token"}, + "chat_state":{ + "chat_id":"chat-1", + "paired":true, + "current_workspace":"/srv/project", + "current_assistant":null, + "current_session_id":"session-1", + "account_remote_context":true + }, + "connected_at":1 + }] + }"#, + ) + .unwrap(); + let data = read_bot_persistence(&path).unwrap().expect("bot data"); + assert_eq!( + data.connections[0] + .chat_state + .current_workspace + .as_ref() + .map(|workspace| workspace.path.as_str()), + Some("/srv/project") + ); + assert!(data.connections[0].chat_state.account_remote_context); + let debug = format!("{data:?}"); + assert!(!debug.contains("secret-token")); + assert!(debug.contains("[REDACTED]")); + } + + #[test] + fn ssh_owner_accepts_legacy_workspace_and_validates_vault_entries() { + let root = test_tempdir("ssh-persistence"); + let workspace_path = root.path().join("remote_workspace.json"); + std::fs::write( + &workspace_path, + r#"{"connectionId":"ssh-user@example.invalid:22","remotePath":"/srv/project"}"#, + ) + .unwrap(); + let legacy = read_legacy_remote_workspaces(&workspace_path) + .unwrap() + .expect("legacy workspace"); + assert_eq!(legacy.len(), 1); + assert!(read_current_remote_workspaces(&workspace_path).is_err()); + write_remote_workspaces(&workspace_path, &legacy).unwrap(); + assert_eq!( + read_current_remote_workspaces(&workspace_path) + .unwrap() + .expect("current workspaces"), + legacy + ); + + let mut vault = new_ssh_vault(); + vault + .store( + "ssh-user@example.invalid:22".to_string(), + "fixture-password", + ) + .unwrap(); + write_ssh_vault(root.path(), &vault).unwrap(); + let loaded = read_ssh_vault(root.path()).unwrap().expect("vault"); + assert_eq!( + loaded + .decrypt("ssh-user@example.invalid:22") + .unwrap() + .as_deref(), + Some("fixture-password") + ); + assert!(!format!("{loaded:?}").contains("fixture-password")); + } + + fn write_legacy_session_fixture( + directory: &Path, + binding: &MachineBinding, + session: &AccountSessionRecord, + local_secret: &[u8; 32], + domains: LegacyAccountSessionKeyDomains<'_>, + ) { + let payload = AccountSessionPayload { + token: session.token.clone(), + user_id: session.user_id.clone(), + master_key_b64: BASE64.encode(session.master_key), + relay_url: session.relay_url.clone(), + device_id: session.device_id.clone(), + }; + let plaintext = serde_json::to_vec(&payload).unwrap(); + let key = derive_legacy_v2_key(binding, local_secret, domains); + let nonce = [0x11; NONCE_SIZE]; + let ciphertext = Aes256Gcm::new_from_slice(&key) + .unwrap() + .encrypt(Nonce::from_slice(&nonce), plaintext.as_slice()) + .unwrap(); + let mut packed = nonce.to_vec(); + packed.extend(ciphertext); + std::fs::write(directory.join("account_session.enc"), BASE64.encode(packed)).unwrap(); + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + std::fs::create_dir_all(&root).expect("test temporary root"); + tempfile::Builder::new() + .prefix(&format!("remote-persistence-{label}-")) + .tempdir_in(root) + .expect("test temporary directory") + } +} From b5d1532ef66455e377ee1901f13c134e2c2a28f6 Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 7 Sep 2026 21:46:37 +0800 Subject: [PATCH 4/7] feat(migration): Integrate Data Migrator Expose migration through a standalone application that can operate while Desktop is shut down and return users to the product afterward. - Add the migration-only delivery profile, independent WebView identity, typed commands, and scan, confirm, and execute screens. - Persist handoff requests with nonce, expiry, product, channel, and capability checks; accept only a run ID on the command line. - Resolve sibling executables from product binary names and inspect active writers before starting offline migration. - Add first-launch onboarding, settings status and actions, startup notifications, and an Installer entry point. - Persist onboarding decisions and report references across restarts. - Build and package the Migrator sidecar with Desktop and Installer, including branded binary projections and packaging checks. - Register local migration operations in the product operation registry and provide localized user-facing controls. Keep migration execution in the standalone host while Desktop and Installer prepare requests and present migration state. --- Cargo.lock | 19 + Cargo.toml | 3 +- .../scripts/build-installer.cjs | 1 + .../scripts/build-installer.test.cjs | 22 + OpenBitFun-Installer/src-tauri/Cargo.toml | 3 + OpenBitFun-Installer/src-tauri/build.rs | 4 +- .../src-tauri/src/installer/commands.rs | 133 +- .../src-tauri/src/installer/mod.rs | 1 + OpenBitFun-Installer/src-tauri/src/lib.rs | 1 + OpenBitFun-Installer/src/App.tsx | 1 + .../src/hooks/useInstaller.ts | 9 +- OpenBitFun-Installer/src/i18n/locales/en.json | 4 +- .../src/i18n/locales/zh-TW.json | 4 +- OpenBitFun-Installer/src/i18n/locales/zh.json | 4 +- OpenBitFun-Installer/src/pages/ThemeSetup.tsx | 20 +- OpenBitFun-Installer/src/types/installer.ts | 2 + .../product-customization-blueprint.md | 20 +- docs/interactive-capabilities/README.md | 16 +- .../capabilities.json | 439 ++++++- .../capabilities/setting.data.migration.md | 63 + .../technical/product-control-open-audit.json | 93 +- .../technical/tauri-command-map.json | 88 +- package.json | 4 +- products/fixtures/acme/locales/en-US.json | 1 + products/fixtures/acme/locales/zh-CN.json | 1 + products/fixtures/acme/locales/zh-TW.json | 1 + products/fixtures/acme/product.jsonc | 5 + products/openbitfun/locales/en-US.json | 1 + products/openbitfun/locales/zh-CN.json | 1 + products/openbitfun/locales/zh-TW.json | 1 + products/openbitfun/product.jsonc | 5 + .../schemas/product-definition.schema.json | 3 +- scripts/check-core-boundaries.test.mjs | 1 + .../core-boundaries/rules/feature-rules.mjs | 1 + scripts/core-boundaries/self-test.mjs | 1 + scripts/data-migrator-tauri-build.mjs | 87 ++ scripts/data-migrator-tauri-build.test.mjs | 71 ++ scripts/desktop-tauri-build.mjs | 114 +- scripts/desktop-tauri-build.test.mjs | 54 + scripts/product-customization/cli.mjs | 2 +- scripts/product-customization/projections.mjs | 3 + .../projections.test.mjs | 5 + scripts/product-customization/resolver.mjs | 24 +- .../product-customization/resolver.test.mjs | 8 + scripts/product-identity-audit.mjs | 3 +- scripts/product-identity-audit.test.mjs | 14 + src/apps/data-migrator/AGENTS.md | 37 + src/apps/data-migrator/Cargo.toml | 34 + src/apps/data-migrator/build.rs | 11 + .../data-migrator/capabilities/migrator.json | 10 + src/apps/data-migrator/gen/.gitignore | 2 + src/apps/data-migrator/src/app_state.rs | 1023 +++++++++++++++ src/apps/data-migrator/src/commands.rs | 88 ++ src/apps/data-migrator/src/lib.rs | 53 + src/apps/data-migrator/src/main.rs | 21 + src/apps/data-migrator/tauri.conf.json | 41 + src/apps/data-migrator/ui/app.js | 273 ++++ src/apps/data-migrator/ui/index.html | 103 ++ src/apps/data-migrator/ui/styles.css | 224 ++++ src/apps/desktop/Cargo.toml | 5 +- src/apps/desktop/build.rs | 2 + .../desktop/src/api/legacy_migration_api.rs | 620 +++++++++ src/apps/desktop/src/api/mod.rs | 1 + src/apps/desktop/src/lib.rs | 18 + .../assembly/product-capabilities/src/lib.rs | 9 + .../product_capabilities.rs | 40 + .../generated/product-control-catalog.json | 439 ++++++- .../generated/remote-surface-registry.json | 62 +- .../src/legacy_migration/mod.rs | 39 +- .../src/remote_surface/table.rs | 5 + .../tests/legacy_migration_contracts.rs | 32 +- .../services/legacy-migration/Cargo.toml | 16 +- .../services/legacy-migration/src/engine.rs | 60 +- .../services/legacy-migration/src/error.rs | 6 + .../services/legacy-migration/src/handoff.rs | 1113 +++++++++++++++++ .../services/legacy-migration/src/lib.rs | 8 + .../legacy-migration/src/onboarding.rs | 283 +++++ .../services/legacy-migration/src/probe.rs | 32 +- .../services/legacy-migration/src/storage.rs | 4 + .../tests/migration_engine_contracts.rs | 55 +- src/crates/services/services-core/Cargo.toml | 1 + .../services-core/src/process_manager.rs | 81 +- .../interactive-capabilities/catalog.json | 137 ++ src/web-ui/src/app/App.tsx | 8 + .../generated/interactive-capabilities.json | 439 ++++++- .../scenes/settings/settingsRegistry.test.ts | 9 +- .../app/scenes/settings/settingsRegistry.ts | 15 + .../src/app/scenes/settings/settingsTypes.ts | 1 + ...legacyMigrationStartupNotification.test.ts | 54 + .../legacyMigrationStartupNotification.ts | 46 + .../api/generated/productControl.ts | 6 +- .../api/generated/remoteSurface.ts | 7 +- .../service-api/LegacyMigrationAPI.test.ts | 30 + .../api/service-api/LegacyMigrationAPI.ts | 177 +++ .../LegacyMigrationSettingsPage.scss | 20 + .../LegacyMigrationSettingsPage.tsx | 410 ++++++ .../i18n/presets/namespaceRegistry.ts | 1 + src/web-ui/src/locales/en-US/settings.json | 1 + .../en-US/settings/legacy-migration.json | 145 +++ src/web-ui/src/locales/zh-CN/settings.json | 1 + .../zh-CN/settings/legacy-migration.json | 133 ++ src/web-ui/src/locales/zh-TW/settings.json | 1 + .../zh-TW/settings/legacy-migration.json | 133 ++ 103 files changed, 7891 insertions(+), 95 deletions(-) create mode 100644 docs/interactive-capabilities/capabilities/setting.data.migration.md create mode 100644 scripts/data-migrator-tauri-build.mjs create mode 100644 scripts/data-migrator-tauri-build.test.mjs create mode 100644 src/apps/data-migrator/AGENTS.md create mode 100644 src/apps/data-migrator/Cargo.toml create mode 100644 src/apps/data-migrator/build.rs create mode 100644 src/apps/data-migrator/capabilities/migrator.json create mode 100644 src/apps/data-migrator/gen/.gitignore create mode 100644 src/apps/data-migrator/src/app_state.rs create mode 100644 src/apps/data-migrator/src/commands.rs create mode 100644 src/apps/data-migrator/src/lib.rs create mode 100644 src/apps/data-migrator/src/main.rs create mode 100644 src/apps/data-migrator/tauri.conf.json create mode 100644 src/apps/data-migrator/ui/app.js create mode 100644 src/apps/data-migrator/ui/index.html create mode 100644 src/apps/data-migrator/ui/styles.css create mode 100644 src/apps/desktop/src/api/legacy_migration_api.rs create mode 100644 src/crates/services/legacy-migration/src/handoff.rs create mode 100644 src/crates/services/legacy-migration/src/onboarding.rs create mode 100644 src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts create mode 100644 src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.test.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.ts create mode 100644 src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.scss create mode 100644 src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.tsx create mode 100644 src/web-ui/src/locales/en-US/settings/legacy-migration.json create mode 100644 src/web-ui/src/locales/zh-CN/settings/legacy-migration.json create mode 100644 src/web-ui/src/locales/zh-TW/settings/legacy-migration.json diff --git a/Cargo.lock b/Cargo.lock index 6d8ce28663..7d9fa167ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5943,6 +5943,23 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "openbitfun-data-migrator" +version = "1.0.0" +dependencies = [ + "openbitfun-core", + "openbitfun-core-types", + "openbitfun-legacy-migration", + "openbitfun-product-capabilities", + "openbitfun-product-domains", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tempfile", + "uuid", +] + [[package]] name = "openbitfun-desktop" version = "1.0.0" @@ -5986,6 +6003,7 @@ dependencies = [ "openbitfun-core", "openbitfun-core-types", "openbitfun-events", + "openbitfun-legacy-migration", "openbitfun-product-domains", "openbitfun-relay-service", "openbitfun-runtime-ports", @@ -6079,6 +6097,7 @@ dependencies = [ "dirs 6.0.0", "fs2", "hex", + "libc", "openbitfun-product-domains", "openbitfun-services-core", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index ded0458a89..b4f13d7fe5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "src/apps/cli", "src/apps/sdk-host", "src/apps/desktop", + "src/apps/data-migrator", "src/apps/server", "src/apps/relay-server", "src/apps/miniapp-market-server", @@ -201,7 +202,7 @@ sqlx = { version = "0.8", default-features = false } rusqlite = { version = "0.32", default-features = false } # Tauri (desktop only) -tauri = { version = "2.11", features = ["unstable", "macos-private-api", "tray-icon"] } +tauri = { version = "2.11" } tauri-plugin-opener = "2.5" tauri-plugin-dialog = "2.7" tauri-plugin-fs = "2.5" diff --git a/OpenBitFun-Installer/scripts/build-installer.cjs b/OpenBitFun-Installer/scripts/build-installer.cjs index 3cc9f52f2c..20bd2776a4 100644 --- a/OpenBitFun-Installer/scripts/build-installer.cjs +++ b/OpenBitFun-Installer/scripts/build-installer.cjs @@ -28,6 +28,7 @@ const STRICT_PAYLOAD_VALIDATION = !isDev; const MIN_APP_EXE_BYTES = 5 * 1024 * 1024; const REQUIRED_PAYLOAD_FILES = [ "openbitfun-desktop.exe", + "openbitfun-data-migrator.exe", "frontend/dist/index.html", "mobile-web/dist/index.html", "resources/ext-host/extension-host.js", diff --git a/OpenBitFun-Installer/scripts/build-installer.test.cjs b/OpenBitFun-Installer/scripts/build-installer.test.cjs index 985b8c9eeb..2bdfb09c11 100644 --- a/OpenBitFun-Installer/scripts/build-installer.test.cjs +++ b/OpenBitFun-Installer/scripts/build-installer.test.cjs @@ -103,16 +103,38 @@ test("all Installer validators share the required runtime file contract", () => path.join(installerRoot, "src-tauri", "src", "installer", "commands.rs"), "utf8" ); + const installerModRs = fs.readFileSync( + path.join(installerRoot, "src-tauri", "src", "installer", "mod.rs"), + "utf8" + ); for (const relativePath of REQUIRED_PAYLOAD_FILES) { assert.match(buildRs, new RegExp(escapeRegExp(relativePath))); if (relativePath === "openbitfun-desktop.exe") { assert.match(commandsRs, /MAIN_APP_EXE/); + } else if (relativePath === "openbitfun-data-migrator.exe") { + assert.match(installerModRs, new RegExp(escapeRegExp(relativePath))); + assert.match(commandsRs, /DATA_MIGRATOR_EXE/); } else { assert.match(commandsRs, new RegExp(escapeRegExp(relativePath))); } } }); +test("Data Migrator launch resolves only the registered installation", () => { + const commandsRs = fs.readFileSync( + path.join(installerRoot, "src-tauri", "src", "installer", "commands.rs"), + "utf8" + ); + const commandStart = commandsRs.indexOf("pub(crate) fn launch_legacy_data_migrator"); + const commandEnd = commandsRs.indexOf("/// Close the installer window.", commandStart); + assert.notEqual(commandStart, -1); + assert.notEqual(commandEnd, -1); + const commandSource = commandsRs.slice(commandStart, commandEnd); + assert.doesNotMatch(commandSource, /request\.install_path/); + assert.match(commandSource, /read_existing_install_from_uninstall_registry/); + assert.match(commandSource, /read_tauri_install_location/); +}); + function minorLine(version) { assert.match(version, /^\d+\.\d+\.\d+$/); return version.split(".").slice(0, 2).join("."); diff --git a/OpenBitFun-Installer/src-tauri/Cargo.toml b/OpenBitFun-Installer/src-tauri/Cargo.toml index e76b8edcff..370c3291f7 100644 --- a/OpenBitFun-Installer/src-tauri/Cargo.toml +++ b/OpenBitFun-Installer/src-tauri/Cargo.toml @@ -30,6 +30,9 @@ zip = "0.6" openbitfun-core-types = { path = "../../src/crates/contracts/core-types" } openbitfun-ai-adapters = { path = "../../src/crates/adapters/ai-adapters" } openbitfun-services-core = { path = "../../src/crates/services/services-core", features = ["json-io"] } +openbitfun-legacy-migration = { path = "../../src/crates/services/legacy-migration" } +openbitfun-product-domains = { path = "../../src/crates/contracts/product-domains", features = ["legacy-migration"] } +uuid = { version = "1", features = ["v4"] } [target.'cfg(windows)'.dependencies] winreg = "0.52" diff --git a/OpenBitFun-Installer/src-tauri/build.rs b/OpenBitFun-Installer/src-tauri/build.rs index 11110fd940..88045a3664 100644 --- a/OpenBitFun-Installer/src-tauri/build.rs +++ b/OpenBitFun-Installer/src-tauri/build.rs @@ -5,8 +5,9 @@ use std::path::{Path, PathBuf}; use zip::write::FileOptions; use zip::{CompressionMethod, ZipWriter}; -const REQUIRED_PAYLOAD_FILES: [&str; 5] = [ +const REQUIRED_PAYLOAD_FILES: [&str; 6] = [ "openbitfun-desktop.exe", + "openbitfun-data-migrator.exe", "frontend/dist/index.html", "mobile-web/dist/index.html", "resources/ext-host/extension-host.js", @@ -14,6 +15,7 @@ const REQUIRED_PAYLOAD_FILES: [&str; 5] = [ ]; fn main() { + println!("cargo:rerun-if-env-changed=OPENBITFUN_RELEASE_CHANNEL"); if let Err(err) = build_embedded_payload() { panic!("failed to build embedded payload: {err}"); } diff --git a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs index 1576058fab..8aca0f10eb 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs @@ -6,12 +6,23 @@ use super::types::{ ConnectionTestResult, DiskSpaceInfo, InstallOptions, InstallProgress, ModelConfig, RemoteModelInfo, }; -use super::MAIN_APP_EXE; +use super::{DATA_MIGRATOR_EXE, MAIN_APP_EXE}; use openbitfun_core_types::{ installer_config_handoff::{ InstallerConfigHandoff, InstallerModelHandoff, INSTALLER_CONFIG_HANDOFF_FILE_NAME, }, - product_identity::{data_namespace, hidden_data_directory}, + product_identity::{data_namespace, hidden_data_directory, product_id}, +}; +#[cfg(target_os = "windows")] +use openbitfun_legacy_migration::{ + launch_trusted_executable, probe_legacy_source, HandoffStore, MigrationOnboardingStore, + MigrationRoots, PlatformExecutableTrustVerifier, ProbeLimits, TrustedInstallationResolver, +}; +#[cfg(target_os = "windows")] +use openbitfun_product_domains::legacy_migration::{ + MigrationPromptChoice, MigrationSelection, MigratorHandoffRequest, + MigratorProtocolCapabilities, MigratorRequestMode, MigratorRequestOrigin, + CURRENT_MIGRATION_FORMAT_VERSION, }; use openbitfun_services_core::json_store::JsonFileStore; use serde::{Deserialize, Serialize}; @@ -22,6 +33,8 @@ use std::fs::File; use std::io::{Cursor, Read}; use std::path::{Path, PathBuf}; use std::sync::LazyLock; +#[cfg(target_os = "windows")] +use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{Emitter, Manager, Window}; #[cfg(target_os = "windows")] @@ -35,14 +48,22 @@ struct WindowsInstallState { const MIN_WINDOWS_APP_EXE_BYTES: u64 = 5 * 1024 * 1024; const PAYLOAD_MANIFEST_FILE: &str = "payload-manifest.json"; -const REQUIRED_PAYLOAD_FILES: [&str; 5] = [ +const REQUIRED_PAYLOAD_FILES: [&str; 6] = [ MAIN_APP_EXE, + DATA_MIGRATOR_EXE, "frontend/dist/index.html", "mobile-web/dist/index.html", "resources/ext-host/extension-host.js", "resources/worker_host.js", ]; const INSTALLER_STATE_FILE: &str = "installer-state.json"; +#[cfg(target_os = "windows")] +const MIGRATOR_HANDOFF_LIFETIME_MS: i64 = 10 * 60 * 1000; +#[cfg(target_os = "windows")] +const RELEASE_CHANNEL: &str = match option_env!("OPENBITFUN_RELEASE_CHANNEL") { + Some(value) => value, + None => "stable", +}; const EMBEDDED_PAYLOAD_ZIP: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/embedded_payload.zip")); @@ -836,6 +857,112 @@ pub(crate) fn launch_application(install_path: String) -> Result<(), String> { Ok(()) } +/// Create an Installer-origin onboarding handoff and launch the installed +/// standalone Data Migrator. The Installer never reads or writes migrated +/// product data itself. +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct LaunchLegacyDataMigratorRequest {} + +#[tauri::command] +pub(crate) fn launch_legacy_data_migrator( + request: LaunchLegacyDataMigratorRequest, +) -> Result { + #[cfg(not(target_os = "windows"))] + { + let _ = request; + return Err( + "Legacy BitFun data migration is not supported by this Installer platform.".to_string(), + ); + } + + #[cfg(target_os = "windows")] + { + let _ = request; + let roots = MigrationRoots::resolve_current_user() + .map_err(|_| "Could not resolve the current-user migration storage.".to_string())?; + let Some(source) = probe_legacy_source(&roots, ProbeLimits::default()) + .map_err(|_| "Could not safely inspect older BitFun data.".to_string())? + else { + return Ok(false); + }; + if !source.supported { + return Err( + "The discovered BitFun data format is not supported by this Data Migrator." + .to_string(), + ); + } + + let now_ms = migration_now_ms(); + let capabilities = MigratorProtocolCapabilities::current(); + let handoff = MigratorHandoffRequest { + protocol_version: capabilities.protocol_version, + mode: MigratorRequestMode::Onboarding, + origin: MigratorRequestOrigin::Installer, + run_id: uuid::Uuid::new_v4().to_string(), + nonce: uuid::Uuid::new_v4().to_string(), + source_id: Some(source.source_id.clone()), + source_fingerprint: Some(source.source_fingerprint.clone()), + selection: MigrationSelection::all(), + caller_process_id: std::process::id(), + product_id: product_id().to_string(), + release_channel: RELEASE_CHANNEL.to_string(), + created_at_ms: now_ms, + expires_at_ms: now_ms.saturating_add(MIGRATOR_HANDOFF_LIFETIME_MS), + required_capabilities: capabilities.capabilities, + }; + HandoffStore::new(roots.clone(), product_id(), RELEASE_CHANNEL) + .write_request(&handoff, now_ms) + .map_err(|_| "Could not create a safe Data Migrator handoff.".to_string())?; + MigrationOnboardingStore::new(roots) + .update(|state| { + state.format_version = CURRENT_MIGRATION_FORMAT_VERSION; + state.source_fingerprint = source.source_fingerprint.clone(); + state.detected_at_ms.get_or_insert(now_ms); + state.choice = MigrationPromptChoice::Unset; + state.last_prompted_version = Some(env!("CARGO_PKG_VERSION").to_string()); + state.run_id = Some(handoff.run_id.clone()); + state.handled_run_id = None; + }) + .map_err(|_| "Could not persist the Data Migrator handoff state.".to_string())?; + + let registered_install_path = super::registry::read_existing_install_from_uninstall_registry() + .map(|registration| registration.install_location) + .or_else(super::registry::read_tauri_install_location) + .ok_or_else(|| { + "The registered OpenBitFun installation could not be found. Repair the installation before starting Data Migrator." + .to_string() + })?; + let desktop = PathBuf::from(registered_install_path).join(MAIN_APP_EXE); + let executable = TrustedInstallationResolver::resolve_sibling( + &desktop, + MAIN_APP_EXE, + DATA_MIGRATOR_EXE, + &PlatformExecutableTrustVerifier, + ) + .map_err(|_| { + "The installed Data Migrator is missing or its signature cannot be trusted. Repair the OpenBitFun installation." + .to_string() + })?; + launch_trusted_executable( + &executable, + &[std::ffi::OsStr::new(handoff.run_id.as_str())], + ) + .map_err(|_| "Could not launch the installed Data Migrator.".to_string())?; + Ok(true) + } +} + +#[cfg(target_os = "windows")] +fn migration_now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + /// Close the installer window. #[tauri::command] pub(crate) fn close_installer(window: Window) { diff --git a/OpenBitFun-Installer/src-tauri/src/installer/mod.rs b/OpenBitFun-Installer/src-tauri/src/installer/mod.rs index c918bb1ecb..fc65d1510e 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/mod.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/mod.rs @@ -6,6 +6,7 @@ mod types; /// Windows main binary file name — must match `src/apps/desktop` `[[bin]]` and Tauri NSIS output. const MAIN_APP_EXE: &str = "openbitfun-desktop.exe"; +const DATA_MIGRATOR_EXE: &str = "openbitfun-data-migrator.exe"; #[cfg(target_os = "windows")] mod registry; diff --git a/OpenBitFun-Installer/src-tauri/src/lib.rs b/OpenBitFun-Installer/src-tauri/src/lib.rs index 3a3904b400..bf14be84ed 100644 --- a/OpenBitFun-Installer/src-tauri/src/lib.rs +++ b/OpenBitFun-Installer/src-tauri/src/lib.rs @@ -21,6 +21,7 @@ pub fn run() { commands::set_theme_preference, commands::uninstall, commands::launch_application, + commands::launch_legacy_data_migrator, commands::close_installer, ]) .run(tauri::generate_context!()) diff --git a/OpenBitFun-Installer/src/App.tsx b/OpenBitFun-Installer/src/App.tsx index 3b71bb5462..cef022e03b 100644 --- a/OpenBitFun-Installer/src/App.tsx +++ b/OpenBitFun-Installer/src/App.tsx @@ -90,6 +90,7 @@ function App() { options={installer.options} setOptions={installer.setOptions} onLaunch={installer.launchApp} + onLaunchMigration={installer.launchLegacyDataMigrator} onClose={installer.closeInstaller} /> ); diff --git a/OpenBitFun-Installer/src/hooks/useInstaller.ts b/OpenBitFun-Installer/src/hooks/useInstaller.ts index a6e21cc0b5..8c7429f3b1 100644 --- a/OpenBitFun-Installer/src/hooks/useInstaller.ts +++ b/OpenBitFun-Installer/src/hooks/useInstaller.ts @@ -38,6 +38,7 @@ export interface UseInstallerReturn { saveModelConfig: () => Promise; testModelConnection: (modelConfig: ModelConfig) => Promise; launchApp: () => Promise; + launchLegacyDataMigrator: () => Promise; closeInstaller: () => void; refreshDiskSpace: (path: string) => Promise; clearInstallError: () => void; @@ -333,6 +334,12 @@ export function useInstaller(): UseInstallerReturn { await invoke('launch_application', { installPath: options.installPath }); }, [options.installPath]); + const launchLegacyDataMigrator = useCallback(async () => { + return invoke('launch_legacy_data_migrator', { + request: {}, + }); + }, []); + const closeInstaller = useCallback(() => { invoke('close_installer'); }, []); @@ -379,7 +386,7 @@ export function useInstaller(): UseInstallerReturn { progress, isInstalling, installationCompleted, error, diskSpace, existingInstall, launchRegisteredUninstaller, install, canConfirmProgress, confirmProgress, retryInstall, backToOptions, - saveModelConfig, testModelConnection, launchApp, closeInstaller, refreshDiskSpace, clearInstallError, + saveModelConfig, testModelConnection, launchApp, launchLegacyDataMigrator, closeInstaller, refreshDiskSpace, clearInstallError, isUninstallMode, isUninstalling, uninstallCompleted, uninstallError, uninstallProgress, startUninstall, }; } diff --git a/OpenBitFun-Installer/src/i18n/locales/en.json b/OpenBitFun-Installer/src/i18n/locales/en.json index 858834ec63..b3394b06bc 100644 --- a/OpenBitFun-Installer/src/i18n/locales/en.json +++ b/OpenBitFun-Installer/src/i18n/locales/en.json @@ -225,7 +225,9 @@ } }, "complete": { - "finish": "Finish" + "finish": "Finish", + "migrateLegacyData": "Migrate old BitFun data after installation", + "migrateLegacyDataDescription": "Starts the installed Data Migrator. It keeps the old data unchanged, asks you to confirm the scope, and reopens OpenBitFun when migration ends." }, "uninstall": { "title": "Uninstall OpenBitFun", diff --git a/OpenBitFun-Installer/src/i18n/locales/zh-TW.json b/OpenBitFun-Installer/src/i18n/locales/zh-TW.json index 9e6e6d4afd..1ea4429e53 100644 --- a/OpenBitFun-Installer/src/i18n/locales/zh-TW.json +++ b/OpenBitFun-Installer/src/i18n/locales/zh-TW.json @@ -225,7 +225,9 @@ } }, "complete": { - "finish": "完成" + "finish": "完成", + "migrateLegacyData": "安裝完成後遷移舊 BitFun 資料", + "migrateLegacyDataDescription": "啟動已安裝的 Data Migrator。它會保持舊資料不變,請你確認遷移範圍,並在遷移結束後重新開啟 OpenBitFun。" }, "uninstall": { "title": "解除安裝 OpenBitFun", diff --git a/OpenBitFun-Installer/src/i18n/locales/zh.json b/OpenBitFun-Installer/src/i18n/locales/zh.json index 36e27b06af..3c476ab074 100644 --- a/OpenBitFun-Installer/src/i18n/locales/zh.json +++ b/OpenBitFun-Installer/src/i18n/locales/zh.json @@ -225,7 +225,9 @@ } }, "complete": { - "finish": "完成" + "finish": "完成", + "migrateLegacyData": "安装完成后迁移旧 BitFun 数据", + "migrateLegacyDataDescription": "启动已安装的 Data Migrator。它会保持旧数据不变,请你确认迁移范围,并在迁移结束后重新打开 OpenBitFun。" }, "uninstall": { "title": "卸载 OpenBitFun", diff --git a/OpenBitFun-Installer/src/pages/ThemeSetup.tsx b/OpenBitFun-Installer/src/pages/ThemeSetup.tsx index 235500a0a5..8aef762ae8 100644 --- a/OpenBitFun-Installer/src/pages/ThemeSetup.tsx +++ b/OpenBitFun-Installer/src/pages/ThemeSetup.tsx @@ -10,10 +10,11 @@ interface ThemeSetupProps { options: InstallOptions; setOptions: React.Dispatch>; onLaunch: () => Promise; + onLaunchMigration: () => Promise; onClose: () => void; } -export function ThemeSetup({ options, setOptions, onLaunch, onClose }: ThemeSetupProps) { +export function ThemeSetup({ options, setOptions, onLaunch, onLaunchMigration, onClose }: ThemeSetupProps) { const { t } = useTranslation(); const [isFinishing, setIsFinishing] = useState(false); const [finishError, setFinishError] = useState(null); @@ -57,7 +58,10 @@ export function ThemeSetup({ options, setOptions, onLaunch, onClose }: ThemeSetu console.warn('Failed to persist theme preference:', err); } - if (options.launchAfterInstall) { + const migratorLaunched = options.migrateLegacyData + ? await onLaunchMigration() + : false; + if (!migratorLaunched && options.launchAfterInstall) { await onLaunch(); } onClose(); @@ -123,6 +127,18 @@ export function ThemeSetup({ options, setOptions, onLaunch, onClose }: ThemeSetu
+ setOptions((prev) => ({ ...prev, migrateLegacyData: checked }))} + label={t('complete.migrateLegacyData')} + /> +

+ {t('complete.migrateLegacyDataDescription')} +

setOptions((prev) => ({ ...prev, launchAfterInstall: checked }))} diff --git a/OpenBitFun-Installer/src/types/installer.ts b/OpenBitFun-Installer/src/types/installer.ts index 8913048820..7f1d18ebe0 100644 --- a/OpenBitFun-Installer/src/types/installer.ts +++ b/OpenBitFun-Installer/src/types/installer.ts @@ -83,6 +83,7 @@ export interface InstallOptions { desktopShortcut: boolean; startMenu: boolean; launchAfterInstall: boolean; + migrateLegacyData: boolean; appLanguage: AppLanguage; themePreference: ThemePreferenceId; modelConfig: ModelConfig | null; @@ -109,6 +110,7 @@ export const DEFAULT_OPTIONS: InstallOptions = { desktopShortcut: true, startMenu: true, launchAfterInstall: true, + migrateLegacyData: false, appLanguage: 'zh-CN', themePreference: SYSTEM_THEME_ID, modelConfig: null, diff --git a/docs/architecture/product-customization-blueprint.md b/docs/architecture/product-customization-blueprint.md index 332424d165..22ff0d80bd 100644 --- a/docs/architecture/product-customization-blueprint.md +++ b/docs/architecture/product-customization-blueprint.md @@ -8,7 +8,7 @@ 本文同时记录长期目标边界与最小架构切片。设计只保留已有或近期有明确消费方的概念,不建立通用 白标平台、构建脚本运行时或跨 GUI/TUI 的组件协议;未在“当前实现”中列出的对象只是后续边界,不应被视为已支持能力。 -## 0. 当前实现(C0a) +## 0. 当前实现(C0b) C0a 实现一个构建期 JSONC 产品定义、严格解析器和确定性解析摘要,入口位于 `products/` 与 `scripts/product-customization/`。默认命令不需要参数;只有多产品仓库、CI 矩阵或外部定义文件需要显式传入 @@ -17,13 +17,15 @@ C0a 实现一个构建期 JSONC 产品定义、严格解析器和确定性解析 当前真实消费者只有: - Desktop build adapter:从解析结果覆盖 Tauri `productName`、`mainBinaryName` 与 bundle identifier; +- Data Migrator build adapter:从解析结果覆盖独立 Tauri 身份,并把 Desktop 与 Migrator 的 sibling binary name + 编译进交接边界; - CLI dev/build wrapper:从同一解析结果设置命令名、隔离定制构建缓存,并按成员 `binaryName` 暂存构建产物; -- First-party Rust artifacts:Desktop/CLI build adapter 通过编译期环境注入 `productId`、 +- First-party Rust artifacts:Desktop/Data Migrator/CLI build adapter 通过编译期环境注入 `productId`、 `dataNamespace` 与由其派生的隐藏目录名,`openbitfun-core-types::product_identity` 作为最小事实 owner,供数据路径、Runtime ownership、Remote Connect 与 Detached Dispatch 复用; `product:check` / `product:explain` 只是构建作者的校验与解释工具,不计作产品字段的生产消费者。C0a 不生成无人读取的 -通用产品 manifest 或 locale projection;Desktop 与 CLI build adapter 直接消费同一次内存解析结果,Rust consumer +通用产品 manifest 或 locale projection;三个 build adapter 直接消费同一次内存解析结果,Rust consumer 只读取随对应产品 artifact 编译进去的不可变事实,不在运行时重新选产品。 产品定义 v1 仅包含已被这些消费者读取的字段,未知字段一律拒绝。localized 名称独立于技术 ID,并按共享 locale contract @@ -35,7 +37,7 @@ GUI/TUI 布局、插件/内置扩展选择、Installer/Store target、更新与 ### 0.1 当前定义与解析契约 -产品定义描述一个 family,其中 Desktop 与 CLI 是分别命名、分别消费的成员;Installer 与 Store 是可能的 Desktop +产品定义描述一个 family,其中 Desktop、Data Migrator 与 CLI 是分别命名、分别消费的成员;Installer 与 Store 是可能的 Desktop 交付目标,不是独立成员,当前也没有对应实现。schema v1 只接受以下已消费字段: ```jsonc @@ -49,6 +51,11 @@ GUI/TUI 布局、插件/内置扩展选择、Installer/Store target、更新与 "binaryName": "acme-desktop", "bundleId": "com.acme.desktop" }, + "dataMigrator": { + "displayNameKey": "product.dataMigrator.name", + "binaryName": "acme-data-migrator", + "bundleId": "com.acme.data-migrator" + }, "cli": { "displayNameKey": "product.cli.name", "binaryName": "acme" @@ -58,7 +65,8 @@ GUI/TUI 布局、插件/内置扩展选择、Installer/Store target、更新与 ``` 解析器先校验完整 family、双方 locale key、owned path 与技术 ID,再选择命令对应成员;digest-bearing `assembly` -只携带 schema/source digest、成员、display-name key、binary/bundle identity、locale contract facts 与 assembly digest。 +只携带 schema/source digest、成员、display-name key、binary/bundle identity、交接所需 sibling binary names、 +locale contract facts 与 assembly digest。 构建 adapter 所需的源路径、localized 名称、输出目录和 default-product 标记保留在外围 build context,不扩展成通用 manifest。相同输入必须产生相同摘要;非默认产品使用 digest-scoped Cargo target 目录,避免复用其他产品的编译期身份。 @@ -71,7 +79,7 @@ i18n locale 集合和 key parity。 - 只修改默认产品定义或资源引用时运行 `pnpm run product:check`;非默认定义运行 `pnpm run product:check -- --product-config `,确保校验实际改动的产品; -- 修改 schema、resolver 或 Desktop/CLI build adapter 行为时,再运行 `pnpm run product:test`; +- 修改 schema、resolver 或 Desktop/Data Migrator/CLI build adapter 行为时,再运行 `pnpm run product:test`; - 打包和平台矩阵只在变更触及对应交付路径时运行,不作为产品定义的默认本地预检。 ## 1. 设计结论 diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 09eac55a30..3bcdd2c880 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # OpenBitFun 功能与设置目录 / OpenBitFun Features & Settings -OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**321 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` Agent 工具。 +OpenBitFun Playbook 当前包含 **22 个功能**和 **22 个设置页**,共 **44 个**用户可理解的条目、**326 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` Agent 工具。 -OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **321** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. +OpenBitFun Playbook currently contains **22 features**, **22 settings pages**, and **326** source-backed sub-capabilities across **44** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. ## 唯一事实源 / Single source of truth @@ -27,20 +27,20 @@ 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 或可解析的源码标记;这些证据不会进入公开目录。当前 **671** 个 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 **671** 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 -- 每个子能力都明确标记为直接控制、委托给专用 Agent 工具、需交互打开或不支持;“打开页面”不会再被统计成“Agent 已控制”。当前覆盖:直接 **48**、委托 **61**、需交互 **212**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用 Agent 工具、需交互打开或不支持;“打开页面”不会再被统计成“Agent 已控制”。当前覆盖:直接 **48**、委托 **61**、需交互 **217**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;Agent 不接触原始 Tauri Command。 -- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 321 项子能力都不会写入 system prompt。 +- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 326 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读 Agent 只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **48 direct**, **61 delegated**, **212 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **48 direct**, **61 delegated**, **217 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. -- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 321 documented items enters the system prompt. +- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 326 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index a8c4149de3..403e22e76f 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "d7a7419ddd673eb733ae8bd33dbb3dcd3b4d1ce0acfc953067a3403fe26d6699", + "digest": "cf1a4fae19862b0613cb1144cead8fcaaa7559b47d5a5f0f3a2c361be991083b", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -136,13 +136,13 @@ ], "counts": { "features": 22, - "settings": 21, - "userFacing": 43, - "documentedItems": 321, + "settings": 22, + "userFacing": 44, + "documentedItems": 326, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 212, + "interactive": 217, "unsupported": 0 } }, @@ -18765,6 +18765,285 @@ "pageId": "data.archived" } }, + { + "id": "setting.data.migration:query", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scan", + "scope", + "launch", + "report", + "reminder" + ], + "kind": "query", + "risk": "read", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:scan", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scan" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:scope", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scope" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "visualSelection", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:launch", + "capabilityId": "setting.data.migration", + "itemIds": [ + "launch" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:report", + "capabilityId": "setting.data.migration", + "itemIds": [ + "report" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:reminder", + "capabilityId": "setting.data.migration", + "itemIds": [ + "reminder" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, { "id": "setting.data.diagnostics:query", "capabilityId": "setting.data.diagnostics", @@ -29565,6 +29844,156 @@ ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.archived/" }, + { + "id": "setting.data.migration", + "kind": "setting", + "categoryId": "data", + "titleZh": "旧版数据迁移", + "titleEn": "Legacy data migration", + "summaryZh": "从本机旧版 BitFun 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", + "summaryEn": "Scan and import supported data from a local legacy BitFun installation, inspect redacted reports, and leave the legacy source unchanged.", + "keywordsZh": [ + "旧版数据迁移", + "BitFun 数据", + "迁移报告", + "导入旧数据", + "Data Migrator" + ], + "keywordsEn": [ + "legacy data migration", + "BitFun data", + "migration report", + "import old data", + "Data Migrator" + ], + "highlightsZh": [ + "只读扫描本机旧版 BitFun 数据", + "按五个高层数据组选择迁移范围", + "通过独立 Data Migrator 导入并查看去敏报告" + ], + "highlightsEn": [ + "Read-only scan of local legacy BitFun data", + "Choose migration scope across five high-level data groups", + "Import through the standalone Data Migrator and inspect redacted reports" + ], + "items": [ + { + "id": "scan", + "titleZh": "只读扫描本机旧版 BitFun 来源及所选数据组", + "titleEn": "Read-only scan the local legacy BitFun source and selected data groups", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." + } + }, + { + "id": "scope", + "titleZh": "选择设置、扩展、会话、记忆和远程连接迁移范围", + "titleEn": "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "control": { + "kind": "open", + "reasonCode": "visualSelection", + "reasonZh": "迁移范围是影响本机持久数据的五组可见选择;Agent 会打开精确入口,由用户确认所需范围。", + "reasonEn": "Migration scope is a visible five-group selection affecting local persisted data; the Agent opens the exact entry so the user can confirm the intended scope." + } + }, + { + "id": "launch", + "titleZh": "确认关闭影响后启动独立 Data Migrator", + "titleEn": "Launch the standalone Data Migrator after confirming shutdown impact", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." + } + }, + { + "id": "report", + "titleZh": "查看最近运行结果和各领域去敏状态", + "titleEn": "Inspect the latest run result and redacted per-domain status", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." + } + }, + { + "id": "reminder", + "titleZh": "恢复已关闭的首次启动迁移提醒", + "titleEn": "Restore the first-start migration reminder after it was disabled", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." + } + } + ], + "stepsZh": [ + "打开设置", + "进入“数据 > 旧版数据迁移”", + "扫描来源、选择范围并在确认关闭影响后启动迁移器" + ], + "stepsEn": [ + "Open Settings", + "Go to Data > Legacy data migration", + "Scan the source, choose scope, and launch the migrator after confirming shutdown impact" + ], + "agentExamplesZh": [ + "打开旧版数据迁移", + "带我查看 BitFun 迁移报告" + ], + "agentExamplesEn": [ + "Open legacy data migration", + "Show me the BitFun migration report" + ], + "destination": { + "kind": "settings", + "pageId": "data.migration" + }, + "operations": [], + "options": [], + "searchTerms": [ + "setting.data.migration", + "旧版数据迁移", + "Legacy data migration", + "数据与诊断", + "Data & diagnostics", + "BitFun 数据", + "迁移报告", + "导入旧数据", + "Data Migrator", + "legacy data migration", + "BitFun data", + "migration report", + "import old data", + "只读扫描本机旧版 BitFun 数据", + "按五个高层数据组选择迁移范围", + "通过独立 Data Migrator 导入并查看去敏报告", + "Read-only scan of local legacy BitFun data", + "Choose migration scope across five high-level data groups", + "Import through the standalone Data Migrator and inspect redacted reports", + "只读扫描本机旧版 BitFun 来源及所选数据组", + "Read-only scan the local legacy BitFun source and selected data groups", + "选择设置、扩展、会话、记忆和远程连接迁移范围", + "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "确认关闭影响后启动独立 Data Migrator", + "Launch the standalone Data Migrator after confirming shutdown impact", + "查看最近运行结果和各领域去敏状态", + "Inspect the latest run result and redacted per-domain status", + "恢复已关闭的首次启动迁移提醒", + "Restore the first-start migration reminder after it was disabled", + "打开旧版数据迁移", + "带我查看 BitFun 迁移报告", + "Open legacy data migration", + "Show me the BitFun migration report" + ], + "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.migration/" + }, { "id": "setting.data.diagnostics", "kind": "setting", diff --git a/docs/interactive-capabilities/capabilities/setting.data.migration.md b/docs/interactive-capabilities/capabilities/setting.data.migration.md new file mode 100644 index 0000000000..f30972a30d --- /dev/null +++ b/docs/interactive-capabilities/capabilities/setting.data.migration.md @@ -0,0 +1,63 @@ + +--- +id: setting.data.migration +kind: setting +category: data +title_zh: "旧版数据迁移" +title_en: "Legacy data migration" +--- + +# 旧版数据迁移 / Legacy data migration + +> 设置 / Setting + +从本机旧版 BitFun 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。 + +Scan and import supported data from a local legacy BitFun installation, inspect redacted reports, and leave the legacy source unchanged. + +## 完整功能清单 / Everything included + +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 只读扫描本机旧版 BitFun 来源及所选数据组 + - Read-only scan the local legacy BitFun source and selected data groups +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 选择设置、扩展、会话、记忆和远程连接迁移范围 + - Choose settings, extensions, sessions, memory, and remote-connection migration scope +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 确认关闭影响后启动独立 Data Migrator + - Launch the standalone Data Migrator after confirming shutdown impact +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 查看最近运行结果和各领域去敏状态 + - Inspect the latest run result and redacted per-domain status +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 恢复已关闭的首次启动迁移提醒 + - Restore the first-start migration reminder after it was disabled + +## 怎么用 / How to use it + +1. 打开设置 + Open Settings +2. 进入“数据 > 旧版数据迁移” + Go to Data > Legacy data migration +3. 扫描来源、选择范围并在确认关闭影响后启动迁移器 + Scan the source, choose scope, and launch the migrator after confirming shutdown impact + +入口 / Entry: OpenBitFun 设置 + +## Agent 可替你做什么 / What an agent can do for you + +| 操作 / Action | 中文说明 | English description | +| --- | --- | --- | +| 打开对应界面 / Open the UI | 进入 OpenBitFun 中对应的功能界面。 | Open the matching feature in OpenBitFun. | + +## 可配置选项 / Configurable options + +| 选项 / Option | 可用值 / Values | 中文说明 | English description | +| --- | --- | --- | --- | +| 在界面中配置 / Configure in the UI | — | 此页面的设置在对应界面中完成。 | Configure this page in its matching UI. | + +## 可以直接对 Agent 说 / Try saying + +- “打开旧版数据迁移” + - “Open legacy data migration” +- “带我查看 BitFun 迁移报告” + - “Show me the BitFun migration report” + +Agent 会先查找相关功能或设置,确认目标后再替你打开、执行或修改。完整能力目录不会预先塞进对话上下文。 + +The agent first finds the relevant feature or setting, confirms the target, and then opens, runs, or changes it for you. The full catalog is never embedded in the conversation context. diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index b2f434a198..0e03ca71ae 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,13 +1,13 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "d7a7419ddd673eb733ae8bd33dbb3dcd3b4d1ce0acfc953067a3403fe26d6699", - "count": 212, + "catalogDigest": "cf1a4fae19862b0613cb1144cead8fcaaa7559b47d5a5f0f3a2c361be991083b", + "count": 217, "reasonCounts": { "externalAuth": 4, "secretEntry": 5, - "unstructuredInteraction": 185, - "visualSelection": 18 + "unstructuredInteraction": 189, + "visualSelection": 19 }, "entries": [ { @@ -3749,6 +3749,91 @@ "command:delete_all_archived_sessions" ] }, + { + "capabilityId": "setting.data.migration", + "itemId": "scan", + "titleZh": "只读扫描本机旧版 BitFun 来源及所选数据组", + "titleEn": "Read-only scan the local legacy BitFun source and selected data groups", + "reasonCode": "unstructuredInteraction", + "reasonZh": "扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user.", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + }, + "evidence": [ + "command:get_legacy_migration_status", + "command:scan_legacy_migration", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#actions.scan" + ] + }, + { + "capabilityId": "setting.data.migration", + "itemId": "scope", + "titleZh": "选择设置、扩展、会话、记忆和远程连接迁移范围", + "titleEn": "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "reasonCode": "visualSelection", + "reasonZh": "迁移范围是影响本机持久数据的五组可见选择;Agent 会打开精确入口,由用户确认所需范围。", + "reasonEn": "Migration scope is a visible five-group selection affecting local persisted data; the Agent opens the exact entry so the user can confirm the intended scope.", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + }, + "evidence": [ + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#sections.scope.title" + ] + }, + { + "capabilityId": "setting.data.migration", + "itemId": "launch", + "titleZh": "确认关闭影响后启动独立 Data Migrator", + "titleEn": "Launch the standalone Data Migrator after confirming shutdown impact", + "reasonCode": "unstructuredInteraction", + "reasonZh": "启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user.", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + }, + "evidence": [ + "command:prepare_legacy_migration", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#confirm.title" + ] + }, + { + "capabilityId": "setting.data.migration", + "itemId": "report", + "titleZh": "查看最近运行结果和各领域去敏状态", + "titleEn": "Inspect the latest run result and redacted per-domain status", + "reasonCode": "unstructuredInteraction", + "reasonZh": "报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user.", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + }, + "evidence": [ + "command:get_legacy_migration_report", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#sections.report.title" + ] + }, + { + "capabilityId": "setting.data.migration", + "itemId": "reminder", + "titleZh": "恢复已关闭的首次启动迁移提醒", + "titleEn": "Restore the first-start migration reminder after it was disabled", + "reasonCode": "unstructuredInteraction", + "reasonZh": "提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it.", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + }, + "evidence": [ + "command:set_legacy_migration_prompt_preference", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#actions.restoreReminder" + ] + }, { "capabilityId": "setting.data.diagnostics", "itemId": "log-path", diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 04b6121d30..ceb25a3836 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,11 +1,11 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "d7a7419ddd673eb733ae8bd33dbb3dcd3b4d1ce0acfc953067a3403fe26d6699", - "commandCount": 666, + "catalogDigest": "cf1a4fae19862b0613cb1144cead8fcaaa7559b47d5a5f0f3a2c361be991083b", + "commandCount": 671, "coverage": { - "commandCount": 666, - "documentedCommandCount": 633, + "commandCount": 671, + "documentedCommandCount": 638, "implementationCommandCount": 33, "implementationDigest": "35539d9c1510287cb47f4a68fe35859b78f93bb06cd66b86e58d878a48d8c509" }, @@ -3230,6 +3230,38 @@ "signature": "fn get_latest_insights() -> Result, String>", "remoteWorkspacePolicy": "LocalOnly" }, + { + "id": "get_legacy_migration_report", + "moduleId": "legacy_migration", + "capabilityId": "setting.data.migration", + "capabilityIds": [ + "setting.data.migration" + ], + "documentedItemIds": [ + "setting.data.migration:report" + ], + "visibility": "documented", + "rustPath": "api::legacy_migration_api::get_legacy_migration_report", + "sourceFile": "src/apps/desktop/src/api/legacy_migration_api.rs", + "signature": "fn get_legacy_migration_report( request: GetLegacyMigrationReportRequest, ) -> Result, LegacyMigrationCommandError>", + "remoteWorkspacePolicy": "LocalOnly" + }, + { + "id": "get_legacy_migration_status", + "moduleId": "legacy_migration", + "capabilityId": "setting.data.migration", + "capabilityIds": [ + "setting.data.migration" + ], + "documentedItemIds": [ + "setting.data.migration:scan" + ], + "visibility": "documented", + "rustPath": "api::legacy_migration_api::get_legacy_migration_status", + "sourceFile": "src/apps/desktop/src/api/legacy_migration_api.rs", + "signature": "fn get_legacy_migration_status( request: EmptyLegacyMigrationRequest, ) -> Result", + "remoteWorkspacePolicy": "LocalOnly" + }, { "id": "get_mcp_prompt", "moduleId": "mcp", @@ -6614,6 +6646,22 @@ "signature": "fn predownload_acp_client_adapter( state: State<'_, AppState>, request: AcpClientIdRequest, ) -> Result<(), String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, + { + "id": "prepare_legacy_migration", + "moduleId": "legacy_migration", + "capabilityId": "setting.data.migration", + "capabilityIds": [ + "setting.data.migration" + ], + "documentedItemIds": [ + "setting.data.migration:launch" + ], + "visibility": "documented", + "rustPath": "api::legacy_migration_api::prepare_legacy_migration", + "sourceFile": "src/apps/desktop/src/api/legacy_migration_api.rs", + "signature": "fn prepare_legacy_migration( app: AppHandle, request: PrepareLegacyMigrationRequest, ) -> Result", + "remoteWorkspacePolicy": "LocalOnly" + }, { "id": "preview_commit_message", "moduleId": "git_agent", @@ -8369,6 +8417,22 @@ "signature": "fn save_web_search_credential( _state: State<'_, AppState>, request: openbitfun_core::service::web_search::SaveWebSearchCredentialRequest, ) -> Result", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "scan_legacy_migration", + "moduleId": "legacy_migration", + "capabilityId": "setting.data.migration", + "capabilityIds": [ + "setting.data.migration" + ], + "documentedItemIds": [ + "setting.data.migration:scan" + ], + "visibility": "documented", + "rustPath": "api::legacy_migration_api::scan_legacy_migration", + "sourceFile": "src/apps/desktop/src/api/legacy_migration_api.rs", + "signature": "fn scan_legacy_migration( request: ScanLegacyMigrationRequest, ) -> Result", + "remoteWorkspacePolicy": "LocalOnly" + }, { "id": "scan_workspace_info", "moduleId": "commands", @@ -8837,6 +8901,22 @@ "signature": "fn set_global_skill_disabled( request: SetGlobalSkillDisabledRequest, ) -> Result", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "set_legacy_migration_prompt_preference", + "moduleId": "legacy_migration", + "capabilityId": "setting.data.migration", + "capabilityIds": [ + "setting.data.migration" + ], + "documentedItemIds": [ + "setting.data.migration:reminder" + ], + "visibility": "documented", + "rustPath": "api::legacy_migration_api::set_legacy_migration_prompt_preference", + "sourceFile": "src/apps/desktop/src/api/legacy_migration_api.rs", + "signature": "fn set_legacy_migration_prompt_preference( request: SetLegacyMigrationPromptPreferenceRequest, ) -> Result", + "remoteWorkspacePolicy": "LocalOnly" + }, { "id": "set_macos_edit_menu_mode", "moduleId": "system", diff --git a/package.json b/package.json index 0e7048af6a..2748bf7658 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,9 @@ "target:gc": "node scripts/cargo-target-gc.mjs", "product:check": "node scripts/product-customization/cli.mjs check", "product:explain": "node scripts/product-customization/cli.mjs explain", - "product:test": "node --test --test-concurrency=1 scripts/product-customization/*.test.mjs scripts/cli-product.test.mjs scripts/desktop-tauri-build.test.mjs", + "product:test": "node --test --test-concurrency=1 scripts/product-customization/*.test.mjs scripts/cli-product.test.mjs scripts/desktop-tauri-build.test.mjs scripts/data-migrator-tauri-build.test.mjs", + "data-migrator:check": "cargo check -p openbitfun-data-migrator", + "data-migrator:build": "node scripts/data-migrator-tauri-build.mjs", "desktop:build": "node scripts/desktop-tauri-build.mjs", "desktop:build:fast": "node scripts/desktop-tauri-build.mjs --debug --no-bundle", "desktop:build:release-fast": "node scripts/desktop-tauri-build.mjs --no-bundle -- --profile release-fast --features devtools", diff --git a/products/fixtures/acme/locales/en-US.json b/products/fixtures/acme/locales/en-US.json index a8d066b01c..fa286b519c 100644 --- a/products/fixtures/acme/locales/en-US.json +++ b/products/fixtures/acme/locales/en-US.json @@ -1,4 +1,5 @@ { "product.cli.name": "Acme CLI", + "product.dataMigrator.name": "Acme Data Migrator", "product.desktop.name": "Acme Desktop" } diff --git a/products/fixtures/acme/locales/zh-CN.json b/products/fixtures/acme/locales/zh-CN.json index 140f3111ef..ed492ddd53 100644 --- a/products/fixtures/acme/locales/zh-CN.json +++ b/products/fixtures/acme/locales/zh-CN.json @@ -1,4 +1,5 @@ { "product.cli.name": "Acme 命令行", + "product.dataMigrator.name": "Acme 数据迁移器", "product.desktop.name": "Acme 桌面版" } diff --git a/products/fixtures/acme/locales/zh-TW.json b/products/fixtures/acme/locales/zh-TW.json index 770e9abd60..a9e7eb4ac9 100644 --- a/products/fixtures/acme/locales/zh-TW.json +++ b/products/fixtures/acme/locales/zh-TW.json @@ -1,4 +1,5 @@ { "product.cli.name": "Acme 命令列", + "product.dataMigrator.name": "Acme 資料遷移器", "product.desktop.name": "Acme 桌面版" } diff --git a/products/fixtures/acme/product.jsonc b/products/fixtures/acme/product.jsonc index 2af9a0dfa4..6b2ab8af27 100644 --- a/products/fixtures/acme/product.jsonc +++ b/products/fixtures/acme/product.jsonc @@ -10,6 +10,11 @@ "binaryName": "acme-desktop", "bundleId": "com.acme.desktop" }, + "dataMigrator": { + "displayNameKey": "product.dataMigrator.name", + "binaryName": "acme-data-migrator", + "bundleId": "com.acme.data-migrator" + }, "cli": { "displayNameKey": "product.cli.name", "binaryName": "acme" diff --git a/products/openbitfun/locales/en-US.json b/products/openbitfun/locales/en-US.json index 92463c4799..e2ef5aa960 100644 --- a/products/openbitfun/locales/en-US.json +++ b/products/openbitfun/locales/en-US.json @@ -1,4 +1,5 @@ { "product.cli.name": "OpenBitFun CLI", + "product.dataMigrator.name": "OpenBitFun Data Migrator", "product.desktop.name": "OpenBitFun" } diff --git a/products/openbitfun/locales/zh-CN.json b/products/openbitfun/locales/zh-CN.json index 92463c4799..223b6b308e 100644 --- a/products/openbitfun/locales/zh-CN.json +++ b/products/openbitfun/locales/zh-CN.json @@ -1,4 +1,5 @@ { "product.cli.name": "OpenBitFun CLI", + "product.dataMigrator.name": "OpenBitFun 数据迁移器", "product.desktop.name": "OpenBitFun" } diff --git a/products/openbitfun/locales/zh-TW.json b/products/openbitfun/locales/zh-TW.json index 92463c4799..61b90a2bfb 100644 --- a/products/openbitfun/locales/zh-TW.json +++ b/products/openbitfun/locales/zh-TW.json @@ -1,4 +1,5 @@ { "product.cli.name": "OpenBitFun CLI", + "product.dataMigrator.name": "OpenBitFun 資料遷移器", "product.desktop.name": "OpenBitFun" } diff --git a/products/openbitfun/product.jsonc b/products/openbitfun/product.jsonc index b6f199e067..29c10f9d8a 100644 --- a/products/openbitfun/product.jsonc +++ b/products/openbitfun/product.jsonc @@ -10,6 +10,11 @@ "binaryName": "openbitfun-desktop", "bundleId": "com.openbitfun.desktop" }, + "dataMigrator": { + "displayNameKey": "product.dataMigrator.name", + "binaryName": "openbitfun-data-migrator", + "bundleId": "com.openbitfun.data-migrator" + }, "cli": { "displayNameKey": "product.cli.name", "binaryName": "openbitfun" diff --git a/products/schemas/product-definition.schema.json b/products/schemas/product-definition.schema.json index a5e530f87e..8ec650671e 100644 --- a/products/schemas/product-definition.schema.json +++ b/products/schemas/product-definition.schema.json @@ -14,9 +14,10 @@ "members": { "type": "object", "additionalProperties": false, - "required": ["desktop", "cli"], + "required": ["desktop", "dataMigrator", "cli"], "properties": { "desktop": { "$ref": "#/$defs/desktopMember" }, + "dataMigrator": { "$ref": "#/$defs/desktopMember" }, "cli": { "$ref": "#/$defs/commonMember" } } } diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index fd91e23ea3..bdb2450a10 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -3900,6 +3900,7 @@ test('services-core capability profiles keep heavy owners out of the empty profi 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_System_Diagnostics_ToolHelp', + 'windows/Win32_System_JobObjects', 'windows/Win32_System_Threading', ]); assert.deepEqual(profiles.get('workspace-instructions'), [ diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 69b71fe238..c576e7ead8 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -1521,6 +1521,7 @@ export const coreClosedFeatureProfileRules = [ 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_System_Diagnostics_ToolHelp', + 'windows/Win32_System_JobObjects', 'windows/Win32_System_Threading', ], exact: true, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 19fb144680..0667091eae 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -472,6 +472,7 @@ export function runManifestParserSelfTest({ 'tokio/time', 'windows/Win32_Foundation', 'windows/Win32_System_Diagnostics_ToolHelp', + 'windows/Win32_System_JobObjects', 'windows/Win32_System_Threading', ], ], diff --git a/scripts/data-migrator-tauri-build.mjs b/scripts/data-migrator-tauri-build.mjs new file mode 100644 index 0000000000..e52219ac7f --- /dev/null +++ b/scripts/data-migrator-tauri-build.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** Builds the standalone, non-updating Data Migrator Tauri bundle. */ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { extractProductConfigArg } from './product-customization/cli.mjs'; +import { productBuildEnvironment } from './product-customization/projections.mjs'; +import { resolveProductDefinition } from './product-customization/resolver.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const APP_DIR = join(ROOT, 'src', 'apps', 'data-migrator'); + +export function prepareDataMigratorTauriConfig( + baseConfigPath, + resolution, + outputDirectory = join(APP_DIR, 'gen'), +) { + if (resolution.assembly.member !== 'dataMigrator') { + throw new Error('Data Migrator packaging requires the dataMigrator product member.'); + } + const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); + const productName = resolution.productNames[resolution.assembly.fallbackLocale] + ?? resolution.productNames[resolution.assembly.defaultLocale]; + config.productName = productName; + config.mainBinaryName = resolution.assembly.binaryName; + config.identifier = resolution.assembly.bundleId; + config.build = { + frontendDist: config.build?.frontendDist || 'ui', + }; + if (config.plugins) { + delete config.plugins.updater; + if (Object.keys(config.plugins).length === 0) delete config.plugins; + } + if (config.bundle) delete config.bundle.createUpdaterArtifacts; + + mkdirSync(outputDirectory, { recursive: true }); + const output = join( + outputDirectory, + `tauri.${resolution.assembly.assemblyDigest}.generated.conf.json`, + ); + writeFileSync(output, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); + return output; +} + +function tauriArguments(raw) { + let offset = 0; + while (raw[offset] === '--') offset += 1; + return raw.slice(offset); +} + +async function main() { + const { productConfig, forwardArgs } = extractProductConfigArg( + tauriArguments(process.argv.slice(2)), + ); + const resolution = resolveProductDefinition({ + rootDir: ROOT, + productConfig, + member: 'dataMigrator', + }); + Object.assign(process.env, productBuildEnvironment(resolution)); + process.env.CI = 'true'; + const generated = prepareDataMigratorTauriConfig( + join(APP_DIR, 'tauri.conf.json'), + resolution, + ); + console.log(`[product] dataMigrator ${resolution.assembly.assemblyDigest}`); + + const tauriBin = join(ROOT, 'node_modules', '.bin', 'tauri'); + const result = spawnSync(tauriBin, ['build', '--config', generated, ...forwardArgs], { + cwd: APP_DIR, + env: process.env, + stdio: 'inherit', + shell: true, + windowsHide: true, + }); + if (result.error) throw result.error; + process.exit(result.status ?? 1); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error?.stack || error); + process.exit(1); + }); +} diff --git a/scripts/data-migrator-tauri-build.test.mjs b/scripts/data-migrator-tauri-build.test.mjs new file mode 100644 index 0000000000..54129c307f --- /dev/null +++ b/scripts/data-migrator-tauri-build.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; + +import { prepareDataMigratorTauriConfig } from './data-migrator-tauri-build.mjs'; +import { productBuildEnvironment } from './product-customization/projections.mjs'; +import { resolveProductDefinition } from './product-customization/resolver.mjs'; + +const ROOT = resolve(import.meta.dirname, '..'); +const APP = join(ROOT, 'src', 'apps', 'data-migrator'); +const ACME = join(ROOT, 'products', 'fixtures', 'acme', 'product.jsonc'); + +test('Data Migrator has an independent product and non-updating bundle identity', () => { + const resolution = resolveProductDefinition({ + rootDir: ROOT, + productConfig: ACME, + member: 'dataMigrator', + }); + const output = prepareDataMigratorTauriConfig( + join(APP, 'tauri.conf.json'), + resolution, + mkdtempSync(join(tmpdir(), 'openbitfun-data-migrator-config-')), + ); + const config = JSON.parse(readFileSync(output, 'utf8')); + + assert.equal(config.productName, 'Acme Data Migrator'); + assert.equal(config.mainBinaryName, 'acme-data-migrator'); + assert.equal(config.identifier, 'com.acme.data-migrator'); + assert.notEqual(config.identifier, 'com.acme.desktop'); + assert.deepEqual(config.build, { frontendDist: 'ui' }); + assert.equal(config.plugins?.updater, undefined); + assert.equal(config.bundle.createUpdaterArtifacts, undefined); + assert.deepEqual(Object.values(config.bundle.resources), ['THIRD_PARTY_NOTICES.md']); + assert.deepEqual(config.app.windows.map(({ label }) => label), ['migrator']); +}); + +test('Data Migrator projection provides both trusted sibling binary names', () => { + const resolution = resolveProductDefinition({ + rootDir: ROOT, + productConfig: ACME, + member: 'dataMigrator', + }); + const environment = productBuildEnvironment(resolution); + + assert.equal(environment.OPENBITFUN_PRODUCT_BINARY_NAME, 'acme-data-migrator'); + assert.equal(environment.OPENBITFUN_DATA_MIGRATOR_BINARY_NAME, 'acme-data-migrator'); + assert.equal(environment.OPENBITFUN_DESKTOP_BINARY_NAME, 'acme-desktop'); +}); + +test('Data Migrator dependency and command closure stays migration-only', () => { + const manifest = readFileSync(join(APP, 'Cargo.toml'), 'utf8'); + const source = readFileSync(join(APP, 'src', 'app_state.rs'), 'utf8'); + const registration = readFileSync(join(APP, 'src', 'lib.rs'), 'utf8'); + const capability = readFileSync(join(APP, 'capabilities', 'migrator.json'), 'utf8'); + + assert.match(manifest, /openbitfun-core[^\n]+features = \["legacy-migration"\]/); + for (const forbidden of ['product-full', 'openbitfun-agent-runtime', 'plugin-runtime']) { + assert.equal(manifest.includes(forbidden), false, `manifest must not include ${forbidden}`); + } + assert.match(source, /product_assembly_plan_for_profile\(DeliveryProfile::DataMigrator\)/); + assert.match(registration, /tauri::generate_handler!/); + assert.match( + readFileSync(join(ROOT, 'scripts', 'data-migrator-tauri-build.mjs'), 'utf8'), + /windowsHide:\s*true/, + ); + for (const forbidden of ['fs:', 'shell:', 'updater:', 'dialog:']) { + assert.equal(capability.includes(forbidden), false, `capability must not include ${forbidden}`); + } +}); diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index fcf1ae263e..e362c16503 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -30,6 +30,7 @@ const LINUX_FLASHGREP_BINARIES = [ 'flashgrep-aarch64-unknown-linux-musl', 'flashgrep-aarch64-unknown-linux-gnu', ]; +const DATA_MIGRATOR_CARGO_BINARY = 'openbitfun-data-migrator'; function tauriBuildArgsFromArgv() { const args = process.argv.slice(2); @@ -53,6 +54,7 @@ async function main() { const desktopDir = join(ROOT, 'src', 'apps', 'desktop'); preparePluginHost(); + const dataMigratorSidecar = prepareDataMigratorSidecar(forward, resolution, desktopDir); // Flashgrep distribution is temporarily suspended. const flashgrepBinary = null; // Tauri CLI reads CI and rejects numeric "1" (common in CI providers). @@ -66,6 +68,7 @@ async function main() { const tauriConfig = prepareTauriConfig(join(desktopDir, 'tauri.conf.json'), { desktopDir, flashgrepBinary, + dataMigratorSidecar, resolution, releaseChannel, }); @@ -96,6 +99,10 @@ async function main() { process.exit(1); } + if (r.status === 0 && forward.includes('--no-bundle')) { + stageNoBundleDataMigrator(dataMigratorSidecar); + } + // Keep only the latest useful Cargo caches for this build profile after tauri build ends. try { const { profileFromTauriBuildArgs, runGcBestEffort, targetFromTauriBuildArgs } = await import( @@ -119,6 +126,100 @@ async function main() { process.exit(r.status ?? 1); } +function rustHostTargetTriple() { + const result = spawnSync('rustc', ['-vV'], { + cwd: ROOT, + encoding: 'utf8', + shell: false, + windowsHide: true, + }); + if (result.error || result.status !== 0) { + const detail = result.error?.message || result.stderr || `exit status ${result.status}`; + throw new Error(`Could not determine the Rust host target: ${detail}`); + } + const host = String(result.stdout).match(/^host:\s*(\S+)$/m)?.[1]; + if (!host) throw new Error('rustc -vV did not report a host target triple.'); + return host; +} + +export function planDataMigratorSidecar( + args, + resolution, + desktopDir, + runtime = {}, +) { + const explicitTarget = optionValue(args, '--target'); + const targetTriple = explicitTarget || runtime.hostTarget || rustHostTargetTriple(); + const profile = args.includes('--debug') ? 'debug' : optionValue(args, '--profile') || 'release'; + const targetDirValue = runtime.cargoTargetDir ?? process.env.CARGO_TARGET_DIR; + const targetDir = targetDirValue + ? isAbsolute(targetDirValue) + ? targetDirValue + : resolve(ROOT, targetDirValue) + : join(ROOT, 'target'); + const windowsTarget = targetTriple.includes('windows'); + const suffix = windowsTarget ? '.exe' : ''; + const artifactDirectory = join(targetDir, ...(explicitTarget ? [explicitTarget] : []), profile); + const cargoArgs = ['build', '-p', 'openbitfun-data-migrator', '--bin', DATA_MIGRATOR_CARGO_BINARY]; + if (explicitTarget) cargoArgs.push('--target', explicitTarget); + if (args.includes('--debug')) { + // Cargo's default profile is the Tauri CLI's debug profile. + } else if (optionValue(args, '--profile')) { + cargoArgs.push('--profile', profile); + } else { + cargoArgs.push('--release'); + } + + const siblingBinaryName = resolution.assembly.memberBinaryNames.dataMigrator; + const externalBinBase = join(desktopDir, 'gen', 'sidecars', siblingBinaryName); + return { + artifactDirectory, + cargoArgs, + externalBinBase, + externalBinInput: `${externalBinBase}-${targetTriple}${suffix}`, + sourceArtifact: join(artifactDirectory, `${DATA_MIGRATOR_CARGO_BINARY}${suffix}`), + siblingArtifact: join(artifactDirectory, `${siblingBinaryName}${suffix}`), + siblingBinaryName, + targetTriple, + }; +} + +function prepareDataMigratorSidecar(args, resolution, desktopDir) { + const plan = planDataMigratorSidecar(args, resolution, desktopDir); + console.log( + `[tauri-build] Building Data Migrator sidecar (${plan.targetTriple}, ${plan.artifactDirectory})` + ); + const result = spawnSync('cargo', plan.cargoArgs, { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + shell: false, + windowsHide: true, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`Data Migrator sidecar build failed with exit code ${result.status}`); + } + if (!existsSync(plan.sourceArtifact)) { + throw new Error(`Data Migrator build did not produce ${plan.sourceArtifact}`); + } + mkdirSync(dirname(plan.externalBinInput), { recursive: true }); + copyFileSync(plan.sourceArtifact, plan.externalBinInput); + if (!plan.externalBinInput.endsWith('.exe')) { + chmodSync(plan.externalBinInput, statSync(plan.externalBinInput).mode | 0o111); + } + return plan; +} + +export function stageNoBundleDataMigrator(plan) { + if (resolve(plan.sourceArtifact) === resolve(plan.siblingArtifact)) return plan.siblingArtifact; + copyFileSync(plan.sourceArtifact, plan.siblingArtifact); + if (!plan.siblingArtifact.endsWith('.exe')) { + chmodSync(plan.siblingArtifact, statSync(plan.siblingArtifact).mode | 0o111); + } + return plan.siblingArtifact; +} + function preparePluginHost() { const result = spawnSync('pnpm', ['run', 'plugin-host:prepare'], { cwd: ROOT, @@ -277,7 +378,7 @@ export function prepareMacOSFlashgrepForSigning( export function prepareTauriConfig( baseConfigPath, - { desktopDir, flashgrepBinary, resolution, releaseChannel } + { desktopDir, flashgrepBinary, dataMigratorSidecar, resolution, releaseChannel } ) { const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); if (resolution) { @@ -289,6 +390,7 @@ export function prepareTauriConfig( config.identifier = resolution.assembly.bundleId; } injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary); + injectDataMigratorSidecar(config, desktopDir, dataMigratorSidecar); // 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) @@ -355,6 +457,16 @@ export function prepareTauriConfig( return generatedConfig; } +function injectDataMigratorSidecar(config, desktopDir, sidecar) { + if (!sidecar) return; + const externalBin = new Set(config.bundle?.externalBin || []); + externalBin.add(toTauriPath(relative(desktopDir, sidecar.externalBinBase))); + config.bundle = { + ...(config.bundle || {}), + externalBin: [...externalBin], + }; +} + const DSH_PROFILE_RESOURCE_SOURCE = '../../../packages/dsh-acp/dist-profile'; const DSH_PROFILE_RESOURCE_TARGET = 'resources/dsh-profile'; const EXTERNAL_FRONTEND_RESOURCE_SOURCE = '../../../dist'; diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 2399ecc306..5c27bcda53 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -5,8 +5,10 @@ import { join } from 'node:path'; import test from 'node:test'; import { configureDesktopWebFontProfile, + planDataMigratorSidecar, prepareMacOSFlashgrepForSigning, prepareTauriConfig, + stageNoBundleDataMigrator, shouldRetryMacDmgBuild, } from './desktop-tauri-build.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; @@ -343,6 +345,58 @@ test('Desktop packaging works without the suspended Flashgrep resource', () => { } }); +test('Desktop packaging builds and projects the matching Data Migrator sidecar', () => { + const fixture = join(tmpdir(), `openbitfun-migrator-sidecar-${process.pid}-${Date.now()}`); + const desktopDir = join(fixture, 'src', 'apps', 'desktop'); + const targetDir = join(fixture, 'target'); + mkdirSync(desktopDir, { recursive: true }); + const baseConfig = join(fixture, 'tauri.conf.json'); + writeFileSync(baseConfig, JSON.stringify({ bundle: { resources: {} } })); + try { + const resolution = resolveProductDefinition({ + rootDir: ROOT, + productConfig: join(ROOT, 'products', 'fixtures', 'acme', 'product.jsonc'), + member: 'desktop', + }); + const plan = planDataMigratorSidecar( + ['--target', 'x86_64-pc-windows-msvc', '--profile', 'release-fast'], + resolution, + desktopDir, + { cargoTargetDir: targetDir }, + ); + assert.deepEqual(plan.cargoArgs, [ + 'build', + '-p', + 'openbitfun-data-migrator', + '--bin', + 'openbitfun-data-migrator', + '--target', + 'x86_64-pc-windows-msvc', + '--profile', + 'release-fast', + ]); + assert.equal( + plan.externalBinInput, + join(desktopDir, 'gen', 'sidecars', 'acme-data-migrator-x86_64-pc-windows-msvc.exe'), + ); + const generated = prepareTauriConfig(baseConfig, { + desktopDir, + flashgrepBinary: join(fixture, 'flashgrep'), + dataMigratorSidecar: plan, + resolution, + }); + const config = JSON.parse(readFileSync(generated, 'utf8')); + assert.deepEqual(config.bundle.externalBin, ['gen/sidecars/acme-data-migrator']); + + mkdirSync(plan.artifactDirectory, { recursive: true }); + writeFileSync(plan.sourceArtifact, 'migrator'); + assert.equal(stageNoBundleDataMigrator(plan), plan.siblingArtifact); + assert.equal(readFileSync(plan.siblingArtifact, 'utf8'), 'migrator'); + } finally { + rmSync(fixture, { force: true, recursive: true }); + } +}); + test('Windows updater installs NSIS packages without showing its progress window', () => { const fixture = join(tmpdir(), `openbitfun-tauri-updater-${process.pid}-${Date.now()}`); const baseConfig = join(fixture, 'tauri.conf.json'); diff --git a/scripts/product-customization/cli.mjs b/scripts/product-customization/cli.mjs index 962fc93d43..225e9fbf71 100644 --- a/scripts/product-customization/cli.mjs +++ b/scripts/product-customization/cli.mjs @@ -58,7 +58,7 @@ export function explainProduct(resolution) { localizedNames: resolution.productNames, localeDigest: resolution.assembly.localeDigest, assemblyDigest: resolution.assembly.assemblyDigest, - implementedScope: 'identity-and-localized-name-c0a', + implementedScope: 'identity-and-localized-name-c0b', }; } diff --git a/scripts/product-customization/projections.mjs b/scripts/product-customization/projections.mjs index 8bf06c09a9..8918e16777 100644 --- a/scripts/product-customization/projections.mjs +++ b/scripts/product-customization/projections.mjs @@ -46,6 +46,9 @@ export function productBuildEnvironment(resolution) { OPENBITFUN_HIDDEN_DATA_DIRECTORY: `.${resolution.assembly.dataNamespace}`, OPENBITFUN_PRODUCT_BINARY_NAME: resolution.assembly.binaryName, OPENBITFUN_PRODUCT_DISPLAY_NAME: fallbackName, + OPENBITFUN_DESKTOP_BINARY_NAME: resolution.assembly.memberBinaryNames.desktop, + OPENBITFUN_DATA_MIGRATOR_BINARY_NAME: + resolution.assembly.memberBinaryNames.dataMigrator, }; if (!resolution.isDefaultProduct) { const cargoTargetRoot = process.env.CARGO_TARGET_DIR diff --git a/scripts/product-customization/projections.test.mjs b/scripts/product-customization/projections.test.mjs index 6f6ffeb472..25334218db 100644 --- a/scripts/product-customization/projections.test.mjs +++ b/scripts/product-customization/projections.test.mjs @@ -32,4 +32,9 @@ test('build environment isolates custom Cargo output without overriding the defa assert.equal(customEnvironment.OPENBITFUN_HIDDEN_DATA_DIRECTORY, '.acme'); assert.equal(customEnvironment.OPENBITFUN_PRODUCT_BINARY_NAME, 'acme'); assert.equal(customEnvironment.OPENBITFUN_PRODUCT_DISPLAY_NAME, 'Acme CLI'); + assert.equal(customEnvironment.OPENBITFUN_DESKTOP_BINARY_NAME, 'acme-desktop'); + assert.equal( + customEnvironment.OPENBITFUN_DATA_MIGRATOR_BINARY_NAME, + 'acme-data-migrator', + ); }); diff --git a/scripts/product-customization/resolver.mjs b/scripts/product-customization/resolver.mjs index dba0b067a5..c522f00ad4 100644 --- a/scripts/product-customization/resolver.mjs +++ b/scripts/product-customization/resolver.mjs @@ -15,9 +15,9 @@ const ROOT_FIELDS = new Set([ 'localeRoot', 'members', ]); -const MEMBERS_FIELDS = new Set(['desktop', 'cli']); +const MEMBERS_FIELDS = new Set(['desktop', 'dataMigrator', 'cli']); const COMMON_MEMBER_FIELDS = new Set(['displayNameKey', 'binaryName']); -const DESKTOP_MEMBER_FIELDS = new Set([...COMMON_MEMBER_FIELDS, 'bundleId']); +const BUNDLED_MEMBER_FIELDS = new Set([...COMMON_MEMBER_FIELDS, 'bundleId']); export class ProductDefinitionError extends Error { constructor(code, message, action) { @@ -192,12 +192,13 @@ function ownedLocaleFile(localeRoot, locale) { function validateMember(raw, member) { const owner = `members.${member}`; const value = requireObject(raw, owner); - rejectUnknownFields(value, member === 'desktop' ? DESKTOP_MEMBER_FIELDS : COMMON_MEMBER_FIELDS, owner); + const bundled = member === 'desktop' || member === 'dataMigrator'; + rejectUnknownFields(value, bundled ? BUNDLED_MEMBER_FIELDS : COMMON_MEMBER_FIELDS, owner); const result = { displayNameKey: requiredString(value.displayNameKey, `${owner}.displayNameKey`), binaryName: binaryName(value.binaryName, `${owner}.binaryName`), }; - if (member === 'desktop') result.bundleId = bundleId(value.bundleId, `${owner}.bundleId`); + if (bundled) result.bundleId = bundleId(value.bundleId, `${owner}.bundleId`); return result; } @@ -234,7 +235,9 @@ function loadProductNames(rootDir, localeRoot, displayNameKeys) { } export function resolveProductDefinition({ rootDir, productConfig, member }) { - if (!['desktop', 'cli'].includes(member)) fail('invalid_member', `Unsupported product member: ${member}`, 'Use desktop or cli.'); + if (!['desktop', 'dataMigrator', 'cli'].includes(member)) { + fail('invalid_member', `Unsupported product member: ${member}`, 'Use desktop, dataMigrator, or cli.'); + } const canonicalRoot = realpathSync.native(resolve(rootDir)); const defaultPath = realpathSync.native(join(canonicalRoot, 'products', 'openbitfun', 'product.jsonc')); const selectedPath = resolve(productConfig || defaultPath); @@ -259,12 +262,17 @@ export function resolveProductDefinition({ rootDir, productConfig, member }) { rejectUnknownFields(members, MEMBERS_FIELDS, 'members'); const normalizedMembers = { desktop: validateMember(members.desktop, 'desktop'), + dataMigrator: validateMember(members.dataMigrator, 'dataMigrator'), cli: validateMember(members.cli, 'cli'), }; const locales = loadProductNames( canonicalRoot, localeRoot, - [normalizedMembers.desktop.displayNameKey, normalizedMembers.cli.displayNameKey], + [ + normalizedMembers.desktop.displayNameKey, + normalizedMembers.dataMigrator.displayNameKey, + normalizedMembers.cli.displayNameKey, + ], ); const selected = normalizedMembers[member]; const assemblyContent = { @@ -273,6 +281,10 @@ export function resolveProductDefinition({ rootDir, productConfig, member }) { productId, dataNamespace, member, + memberBinaryNames: { + desktop: normalizedMembers.desktop.binaryName, + dataMigrator: normalizedMembers.dataMigrator.binaryName, + }, displayNameKey: selected.displayNameKey, binaryName: selected.binaryName, localeDigest: locales.digest, diff --git a/scripts/product-customization/resolver.test.mjs b/scripts/product-customization/resolver.test.mjs index 4c2283601c..79eae41705 100644 --- a/scripts/product-customization/resolver.test.mjs +++ b/scripts/product-customization/resolver.test.mjs @@ -12,6 +12,7 @@ const ACME = join(ROOT, 'products', 'fixtures', 'acme', 'product.jsonc'); test('default and custom members resolve through one deterministic contract', () => { const openbitfun = resolveProductDefinition({ rootDir: ROOT, member: 'desktop' }); const desktop = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'desktop' }); + const dataMigrator = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'dataMigrator' }); const cli = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'cli' }); assert.equal(openbitfun.assembly.productId, 'openbitfun'); @@ -19,9 +20,16 @@ test('default and custom members resolve through one deterministic contract', () assert.equal(openbitfun.assembly.binaryName, 'openbitfun-desktop'); assert.equal(openbitfun.assembly.bundleId, 'com.openbitfun.desktop'); assert.equal(desktop.assembly.bundleId, 'com.acme.desktop'); + assert.equal(dataMigrator.assembly.binaryName, 'acme-data-migrator'); + assert.equal(dataMigrator.assembly.bundleId, 'com.acme.data-migrator'); + assert.deepEqual(dataMigrator.assembly.memberBinaryNames, { + desktop: 'acme-desktop', + dataMigrator: 'acme-data-migrator', + }); assert.equal(cli.assembly.binaryName, 'acme'); assert.equal(cli.assembly.bundleId, undefined); assert.notEqual(desktop.assembly.assemblyDigest, cli.assembly.assemblyDigest); + assert.notEqual(desktop.assembly.assemblyDigest, dataMigrator.assembly.assemblyDigest); assert.equal( resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'desktop' }) .assembly.assemblyDigest, diff --git a/scripts/product-identity-audit.mjs b/scripts/product-identity-audit.mjs index b24e5aeb32..35ed799fc9 100644 --- a/scripts/product-identity-audit.mjs +++ b/scripts/product-identity-audit.mjs @@ -5,7 +5,7 @@ * * The normal product is OpenBitFun-only. Legacy exceptions are restricted to * the exact data-directory ignore entry and the one-time migration documents, - * service boundary, and fixtures used for in-place upgrades. + * migrator app/service boundary, and fixtures used for in-place upgrades. */ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, statSync } from 'node:fs'; @@ -32,6 +32,7 @@ const retiredIdentityDataBoundaryFiles = new Set([ 'src/apps/mobile/harmonyos/entry/src/main/resources/base/profile/backup_config.json', ]); const retiredIdentityDataBoundaryPrefixes = Object.freeze([ + 'src/apps/data-migrator/', 'src/crates/assembly/core/src/legacy_migration/', 'src/crates/services/legacy-migration/', ]); diff --git a/scripts/product-identity-audit.test.mjs b/scripts/product-identity-audit.test.mjs index 3f8fbf6bbd..5c2abf4278 100644 --- a/scripts/product-identity-audit.test.mjs +++ b/scripts/product-identity-audit.test.mjs @@ -131,6 +131,13 @@ test('limits retired identity data to the one-time production migration boundary ), [], ); + assert.deepEqual( + violationsFor( + `const sourceLabel = "${retiredName}";`, + 'src/apps/data-migrator/ui/app.js', + ), + [], + ); assert.equal( violationsFor( `const SOURCE_PRODUCT: &str = "${retiredLowerName}";`, @@ -138,6 +145,13 @@ test('limits retired identity data to the one-time production migration boundary ).length, 1, ); + assert.equal( + violationsFor( + `const sourceLabel = "${retiredName}";`, + 'src/apps/desktop/src/example.rs', + ).length, + 1, + ); }); test('allows retired Harmony identifiers only at the upgrade identity boundary', () => { diff --git a/src/apps/data-migrator/AGENTS.md b/src/apps/data-migrator/AGENTS.md new file mode 100644 index 0000000000..3fdd584170 --- /dev/null +++ b/src/apps/data-migrator/AGENTS.md @@ -0,0 +1,37 @@ +# Data Migrator Agent Guide + +Scope: this guide applies to `src/apps/data-migrator`. + +This app is the offline, local-only host for importing legacy BitFun data. It +must remain a separate executable and WebView identity from Desktop. + +## Guardrails + +- Select `DeliveryProfile::DataMigrator` and only the Core + `legacy-migration` feature. Do not add `product-full`, Agent Runtime, + plugin runtime, normal session startup, updater, shell, or frontend + filesystem capabilities. +- Accept only the handoff `run_id` on the command line. Derive the request path + from `MigrationRoots`; never accept a request or executable path from UI or + command-line input. +- Keep all filesystem, process, credential, and restart work in Rust. The UI + may call only the typed commands registered in `src/lib.rs`. +- Report domain, phase, and counts. Do not invent progress percentages or emit + secrets, user content, credential values, or absolute paths in errors. +- Cancellation is advisory and may be honored only at engine-declared safe + boundaries. Closing during execution requests cancellation and keeps the + window open until a safe boundary. +- The migrator never updates itself. Resolve Desktop as a verified sibling + binary using product-definition projections and the trusted installation + resolver. + +## Verification + +```bash +cargo test -p openbitfun-data-migrator +node --test scripts/data-migrator-tauri-build.test.mjs +``` + +Run `pnpm run check:core-boundaries` when dependencies or delivery-profile +selection change. Packaging, signing, and UI interaction are separate explicit +verification steps. diff --git a/src/apps/data-migrator/Cargo.toml b/src/apps/data-migrator/Cargo.toml new file mode 100644 index 0000000000..8c112cad8d --- /dev/null +++ b/src/apps/data-migrator/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "openbitfun-data-migrator" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "OpenBitFun offline legacy data migrator" + +[lib] +name = "openbitfun_data_migrator_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[[bin]] +name = "openbitfun-data-migrator" +path = "src/main.rs" + +[build-dependencies] +tauri-build = { workspace = true } + +[dependencies] +openbitfun-core = { path = "../../crates/assembly/core", features = ["legacy-migration"] } +openbitfun-core-types = { path = "../../crates/contracts/core-types" } +openbitfun-legacy-migration = { path = "../../crates/services/legacy-migration" } +openbitfun-product-capabilities = { path = "../../crates/assembly/product-capabilities" } +openbitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["legacy-migration"] } +serde = { workspace = true } +serde_json = { workspace = true } +tauri = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +uuid = { workspace = true } + +[lints] +workspace = true diff --git a/src/apps/data-migrator/build.rs b/src/apps/data-migrator/build.rs new file mode 100644 index 0000000000..2b49de0736 --- /dev/null +++ b/src/apps/data-migrator/build.rs @@ -0,0 +1,11 @@ +fn main() { + for name in [ + "OPENBITFUN_RELEASE_CHANNEL", + "OPENBITFUN_PRODUCT_ID", + "OPENBITFUN_DESKTOP_BINARY_NAME", + "OPENBITFUN_DATA_MIGRATOR_BINARY_NAME", + ] { + println!("cargo:rerun-if-env-changed={name}"); + } + tauri_build::build(); +} diff --git a/src/apps/data-migrator/capabilities/migrator.json b/src/apps/data-migrator/capabilities/migrator.json new file mode 100644 index 0000000000..444fec74c0 --- /dev/null +++ b/src/apps/data-migrator/capabilities/migrator.json @@ -0,0 +1,10 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "migrator", + "description": "Minimal local capability set for OpenBitFun Data Migrator", + "windows": ["migrator"], + "permissions": [ + "core:default", + "core:window:allow-close" + ] +} diff --git a/src/apps/data-migrator/gen/.gitignore b/src/apps/data-migrator/gen/.gitignore new file mode 100644 index 0000000000..d6b7ef32c8 --- /dev/null +++ b/src/apps/data-migrator/gen/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/apps/data-migrator/src/app_state.rs b/src/apps/data-migrator/src/app_state.rs new file mode 100644 index 0000000000..dc74d4ded7 --- /dev/null +++ b/src/apps/data-migrator/src/app_state.rs @@ -0,0 +1,1023 @@ +use openbitfun_core::legacy_migration::adapters_for_groups; +use openbitfun_core_types::product_identity::product_id; +use openbitfun_legacy_migration::{ + blocking_writer_processes_for_product, launch_trusted_executable, probe_legacy_source, + CancellationToken, HandoffDisposition, HandoffStore, LegacyMigrationError, + LegacyMigrationResult, MigrationEngine, MigrationLayout, MigrationOnboardingStore, + MigrationRoots, NoCrashInjection, PlatformExecutableTrustVerifier, ProbeLimits, + TrustedInstallationResolver, WriterProcess, +}; +use openbitfun_product_capabilities::{product_assembly_plan_for_profile, DeliveryProfile}; +use openbitfun_product_domains::legacy_migration::{ + LegacySourceDescriptor, MigrationPhase, MigrationPlan, MigrationProgressEvent, + MigrationPromptChoice, MigrationRunReport, MigrationRunStatus, MigrationSelection, + MigratorHandoffRequest, MigratorProtocolCapabilities, MigratorRequestMode, ScanFinding, + CURRENT_MIGRATION_FORMAT_VERSION, +}; +use serde::Serialize; +use std::ffi::OsStr; +use std::path::Path; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const RELEASE_CHANNEL: &str = match option_env!("OPENBITFUN_RELEASE_CHANNEL") { + Some(value) => value, + None => "stable", +}; +const DESKTOP_BINARY_NAME: &str = match option_env!("OPENBITFUN_DESKTOP_BINARY_NAME") { + Some(value) => value, + None => "openbitfun-desktop", +}; +const DATA_MIGRATOR_BINARY_NAME: &str = match option_env!("OPENBITFUN_DATA_MIGRATOR_BINARY_NAME") { + Some(value) => value, + None => "openbitfun-data-migrator", +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CommandError { + pub code: String, + pub message: String, + pub recoverable: bool, +} + +impl CommandError { + fn new(code: &str, message: &str, recoverable: bool) -> Self { + Self { + code: code.to_string(), + message: message.to_string(), + recoverable, + } + } + + pub(crate) fn worker_failed() -> Self { + Self::new( + "worker_failed", + "The migration worker stopped before returning a result.", + true, + ) + } + + fn operation_in_progress() -> Self { + Self::new( + "operation_in_progress", + "Another migration operation is still running.", + true, + ) + } + + fn from_legacy(error: &LegacyMigrationError) -> Self { + match error { + LegacyMigrationError::PathUnavailable(_) => Self::new( + "path_unavailable", + "A required data location is unavailable for the current user.", + true, + ), + LegacyMigrationError::SourceEqualsTarget(_) => Self::new( + "source_equals_target", + "The legacy and OpenBitFun data locations are not safely separated.", + false, + ), + LegacyMigrationError::UnsupportedSource(_) => Self::new( + "unsupported_source", + "This legacy BitFun data format is not supported by this migrator.", + false, + ), + LegacyMigrationError::InvalidRequest(_) => Self::new( + "invalid_handoff", + "The migration handoff could not be authenticated or has expired.", + false, + ), + LegacyMigrationError::InvalidPlan(_) => Self::new( + "invalid_plan", + "The saved migration plan no longer matches this request or source.", + true, + ), + LegacyMigrationError::PathEscape(_) | LegacyMigrationError::LinkedPath(_) => Self::new( + "unsafe_source_path", + "Migration stopped because a source path failed its safety check.", + false, + ), + LegacyMigrationError::ResourceLimit(_) => Self::new( + "resource_limit", + "Migration stopped at a configured safety limit.", + true, + ), + LegacyMigrationError::LockUnavailable => Self::new( + "migration_locked", + "Another migration process currently owns the migration lock.", + true, + ), + LegacyMigrationError::Cancelled => Self::new( + "cancelled", + "Migration was cancelled at a safe boundary.", + true, + ), + LegacyMigrationError::ProcessInspection(_) => Self::new( + "process_inspection_failed", + "OpenBitFun could not verify that all data-writing processes have stopped.", + true, + ), + LegacyMigrationError::UntrustedExecutable(_) + | LegacyMigrationError::TrustedInstallationUnavailable(_) => Self::new( + "trusted_installation_unavailable", + "The signed OpenBitFun installation could not be verified.", + true, + ), + LegacyMigrationError::InjectedCrash(_) => Self::new( + "migration_interrupted", + "Migration was interrupted and can be resumed from its journal.", + true, + ), + LegacyMigrationError::Domain { .. } => Self::new( + "domain_failed", + "One migration domain failed validation. Other verified domains remain intact.", + true, + ), + LegacyMigrationError::Io { .. } + | LegacyMigrationError::Json { .. } + | LegacyMigrationError::Sqlite { .. } => Self::new( + "storage_failed", + "Migration could not safely read or write one of its data stores.", + true, + ), + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MigratorView { + pub delivery_profile: String, + pub protocol: MigratorProtocolCapabilities, + pub mode: MigratorRequestMode, + pub source: Option, + pub selection: MigrationSelection, + pub findings: Vec, + pub plan: Option, + pub report: Option, + pub progress: Option, + pub blockers: Vec, + pub status: MigrationRunStatus, + pub running: bool, + pub can_execute: bool, + pub recovery: bool, + pub error: Option, +} + +#[derive(Debug)] +struct MigratorSession { + roots: MigrationRoots, + request: MigratorHandoffRequest, + disposition: HandoffDisposition, + source: Option, + selection: MigrationSelection, + findings: Vec, + plan: Option, + report: Option, + progress: Option, + blockers: Vec, + status: MigrationRunStatus, + running: bool, + error: Option, + cancellation: CancellationToken, +} + +#[derive(Clone)] +pub(crate) struct MigratorCoordinator { + session: Arc>, +} + +impl MigratorCoordinator { + pub(crate) fn bootstrap(run_id: &str) -> LegacyMigrationResult { + Self::bootstrap_with( + run_id, + MigrationRoots::resolve_current_user()?, + product_id(), + RELEASE_CHANNEL, + ) + } + + fn bootstrap_with( + run_id: &str, + roots: MigrationRoots, + expected_product_id: &str, + expected_release_channel: &str, + ) -> LegacyMigrationResult { + let product_plan = product_assembly_plan_for_profile(DeliveryProfile::DataMigrator); + if !product_plan.capability_set().ids().is_empty() + || !product_plan.capability_assembly().agent_ids().is_empty() + || !product_plan.feature_groups().is_empty() + { + return Err(LegacyMigrationError::InvalidRequest( + "data migrator delivery profile unexpectedly selected runtime capabilities" + .to_string(), + )); + } + + let store = HandoffStore::new(roots.clone(), expected_product_id, expected_release_channel); + let handoff = store.load_request(run_id, now_ms())?; + let request = handoff.request().clone(); + let source = probe_bound_source(&roots, &request)?; + let plan = store.load_authorized_plan(&handoff)?; + let report = handoff + .layout() + .read_json::(&handoff.layout().report_path())?; + let selection = plan + .as_ref() + .map(|plan| plan.selection.clone()) + .unwrap_or_else(|| request.selection.clone()); + let findings = plan + .as_ref() + .map(|plan| plan.findings.clone()) + .unwrap_or_default(); + let status = report + .as_ref() + .map(|report| report.status) + .unwrap_or_else(|| { + if plan.is_some() { + MigrationRunStatus::Planned + } else if source.is_some() { + MigrationRunStatus::Discovered + } else { + MigrationRunStatus::default() + } + }); + let blockers = writer_processes(request.caller_process_id)?; + + Ok(Self { + session: Arc::new(Mutex::new(MigratorSession { + roots, + request, + disposition: handoff.disposition(), + source, + selection, + findings, + plan, + report, + progress: None, + blockers, + status, + running: false, + error: None, + cancellation: CancellationToken::default(), + })), + }) + } + + pub(crate) fn snapshot(&self) -> MigratorView { + let session = self.lock(); + snapshot(&session) + } + + pub(crate) fn scan(&self, selection: MigrationSelection) -> Result { + let (roots, request, cancellation) = self.begin_operation(&selection)?; + let coordinator = self.clone(); + self.spawn_worker("legacy-migration-scan", move || { + coordinator.scan_background(roots, request, selection, cancellation); + }) + } + + fn scan_background( + &self, + roots: MigrationRoots, + request: MigratorHandoffRequest, + selection: MigrationSelection, + cancellation: CancellationToken, + ) { + let result = (|| { + let source = probe_bound_source(&roots, &request)?.ok_or_else(|| { + LegacyMigrationError::UnsupportedSource( + "no supported legacy BitFun data was discovered".to_string(), + ) + })?; + let engine = migration_engine(roots.clone(), &selection)?; + let scans = engine.scan(&selection, &cancellation)?; + Ok::<_, LegacyMigrationError>(( + source, + scans + .into_iter() + .map(|scan| scan.finding) + .collect::>(), + )) + })(); + + let mut session = self.lock(); + session.running = false; + match result { + Ok((source, findings)) => { + session.source = Some(source); + session.selection = selection; + session.findings = findings; + session.plan = None; + session.report = None; + session.status = MigrationRunStatus::Scanned; + session.progress = Some(MigrationProgressEvent { + run_id: session.request.run_id.clone(), + phase: MigrationPhase::Scan, + processed: session.selection.expanded_domains().len() as u64, + total: session.selection.expanded_domains().len() as u64, + safe_to_cancel: true, + code: "scan_completed".to_string(), + ..MigrationProgressEvent::default() + }); + session.error = None; + } + Err(error) => { + self.finish_error_locked(&mut session, &error); + } + } + } + + pub(crate) fn prepare( + &self, + selection: MigrationSelection, + ) -> Result { + let (roots, request, cancellation) = self.begin_operation(&selection)?; + let coordinator = self.clone(); + self.spawn_worker("legacy-migration-plan", move || { + coordinator.prepare_background(roots, request, selection, cancellation); + }) + } + + fn prepare_background( + &self, + roots: MigrationRoots, + request: MigratorHandoffRequest, + selection: MigrationSelection, + cancellation: CancellationToken, + ) { + let result = (|| { + let source = probe_bound_source(&roots, &request)?.ok_or_else(|| { + LegacyMigrationError::UnsupportedSource( + "no supported legacy BitFun data was discovered".to_string(), + ) + })?; + let engine = migration_engine(roots, &selection)?; + let plan = engine.plan_with_run_id( + &source, + selection.clone(), + request.run_id.clone(), + &cancellation, + )?; + let blockers = writer_processes(request.caller_process_id)?; + Ok::<_, LegacyMigrationError>((source, plan, blockers)) + })(); + + let mut session = self.lock(); + session.running = false; + match result { + Ok((source, plan, blockers)) => { + session.source = Some(source); + session.selection = selection; + session.findings = plan.findings.clone(); + session.plan = Some(plan); + session.report = None; + session.blockers = blockers; + session.status = MigrationRunStatus::Planned; + session.progress = Some(MigrationProgressEvent { + run_id: session.request.run_id.clone(), + phase: MigrationPhase::Plan, + processed: session.selection.expanded_domains().len() as u64, + total: session.selection.expanded_domains().len() as u64, + safe_to_cancel: true, + code: "plan_ready".to_string(), + ..MigrationProgressEvent::default() + }); + session.error = None; + } + Err(error) => { + self.finish_error_locked(&mut session, &error); + } + } + } + + pub(crate) fn refresh_blockers(&self) -> Result { + let caller_process_id = self.lock().request.caller_process_id; + match writer_processes(caller_process_id) { + Ok(blockers) => { + let mut session = self.lock(); + session.blockers = blockers; + session.error = None; + Ok(snapshot(&session)) + } + Err(error) => { + let mut session = self.lock(); + Err(self.finish_error_locked(&mut session, &error)) + } + } + } + + pub(crate) fn start(&self, plan_hash: String) -> Result { + let (roots, request, plan, cancellation) = { + let mut session = self.lock(); + if session.running { + return Err(CommandError::operation_in_progress()); + } + let plan = session.plan.clone().ok_or_else(|| { + CommandError::new( + "plan_required", + "Run the preflight plan before starting migration.", + true, + ) + })?; + if plan.plan_hash != plan_hash { + return Err(CommandError::new( + "stale_plan", + "The confirmed plan is no longer the active migration plan.", + true, + )); + } + let store = HandoffStore::new(session.roots.clone(), product_id(), RELEASE_CHANNEL); + let handoff = store + .load_request(&session.request.run_id, now_ms()) + .map_err(|error| CommandError::from_legacy(&error))?; + store + .authorize_plan(&handoff, &plan, now_ms()) + .map_err(|error| CommandError::from_legacy(&error))?; + + session.cancellation = CancellationToken::default(); + session.running = true; + session.error = None; + session.status = MigrationRunStatus::WaitingForProcesses; + session.progress = Some(MigrationProgressEvent { + run_id: session.request.run_id.clone(), + phase: MigrationPhase::Acquire, + processed: 0, + total: plan.steps.len() as u64, + safe_to_cancel: true, + code: "waiting_for_writer_processes".to_string(), + ..MigrationProgressEvent::default() + }); + ( + session.roots.clone(), + session.request.clone(), + plan, + session.cancellation.clone(), + ) + }; + + let coordinator = self.clone(); + self.spawn_worker("legacy-data-migration", move || { + coordinator.execute_background(roots, request, plan, cancellation); + }) + } + + pub(crate) fn cancel(&self) -> MigratorView { + let mut session = self.lock(); + session.cancellation.cancel(); + if let Some(progress) = &mut session.progress { + progress.code = if progress.safe_to_cancel { + "cancellation_requested".to_string() + } else { + "cancellation_pending_safe_boundary".to_string() + }; + } + snapshot(&session) + } + + pub(crate) fn is_running(&self) -> bool { + self.lock().running + } + + pub(crate) fn finish_and_restart( + &self, + choice: MigrationPromptChoice, + ) -> Result<(), CommandError> { + if self.is_running() { + return Err(CommandError::operation_in_progress()); + } + if choice == MigrationPromptChoice::Unset { + return Err(CommandError::new( + "invalid_prompt_choice", + "Choose whether to migrate now, be reminded later, or stop reminders.", + true, + )); + } + if choice == MigrationPromptChoice::MigrateNow && self.lock().report.is_none() { + return Err(CommandError::new( + "migration_result_required", + "A completed or recoverable migration report is required before finishing.", + true, + )); + } + + let result = (|| { + self.persist_prompt_choice(choice)?; + self.restart_desktop() + })(); + if let Err(error) = result { + let mut session = self.lock(); + let command_error = self.finish_error_locked(&mut session, &error); + return Err(command_error); + } + Ok(()) + } + + pub(crate) fn close_and_restart(&self) -> Result<(), CommandError> { + let choice = if self.lock().report.is_some() { + MigrationPromptChoice::MigrateNow + } else { + MigrationPromptChoice::RemindLater + }; + self.finish_and_restart(choice) + } + + fn begin_operation( + &self, + selection: &MigrationSelection, + ) -> Result<(MigrationRoots, MigratorHandoffRequest, CancellationToken), CommandError> { + let mut session = self.lock(); + if session.running { + return Err(CommandError::operation_in_progress()); + } + validate_selection(&session.request, selection)?; + session.cancellation = CancellationToken::default(); + session.running = true; + session.error = None; + session.progress = Some(MigrationProgressEvent { + run_id: session.request.run_id.clone(), + phase: MigrationPhase::Scan, + processed: 0, + total: selection.expanded_domains().len() as u64, + safe_to_cancel: true, + code: "scanning_source".to_string(), + ..MigrationProgressEvent::default() + }); + Ok(( + session.roots.clone(), + session.request.clone(), + session.cancellation.clone(), + )) + } + + fn spawn_worker( + &self, + name: &str, + worker: impl FnOnce() + Send + 'static, + ) -> Result { + if std::thread::Builder::new() + .name(name.to_string()) + .spawn(worker) + .is_err() + { + let mut session = self.lock(); + session.running = false; + let error = CommandError::worker_failed(); + session.error = Some(error.clone()); + return Err(error); + } + + Ok(self.snapshot()) + } + + fn execute_background( + &self, + roots: MigrationRoots, + request: MigratorHandoffRequest, + plan: MigrationPlan, + cancellation: CancellationToken, + ) { + loop { + if cancellation.is_cancelled() { + self.finish_cancelled_before_execution(&plan); + return; + } + match writer_processes(request.caller_process_id) { + Ok(blockers) => { + let done = blockers.is_empty(); + let mut session = self.lock(); + session.blockers = blockers; + if let Some(progress) = &mut session.progress { + progress.code = if done { + "writer_processes_stopped".to_string() + } else { + "waiting_for_writer_processes".to_string() + }; + } + drop(session); + if done { + break; + } + } + Err(error) => { + let mut session = self.lock(); + session.running = false; + self.finish_error_locked(&mut session, &error); + return; + } + } + std::thread::sleep(Duration::from_millis(500)); + } + + let engine = match migration_engine(roots.clone(), &plan.selection) { + Ok(engine) => engine, + Err(error) => { + let mut session = self.lock(); + session.running = false; + self.finish_error_locked(&mut session, &error); + return; + } + }; + let coordinator = self.clone(); + let result = engine.execute_with_progress( + &plan, + &cancellation, + &NoCrashInjection, + move |progress| coordinator.record_progress(progress), + ); + + let mut session = self.lock(); + session.running = false; + match result { + Ok(report) => { + session.status = report.status; + session.report = Some(report); + session.error = None; + drop(session); + let _ = self.persist_prompt_choice(MigrationPromptChoice::MigrateNow); + } + Err(error) => { + let layout = MigrationLayout::new(&roots, &plan.run_id); + if let Ok(Some(report)) = + layout.read_json::(&layout.report_path()) + { + session.status = report.status; + session.report = Some(report); + } else if matches!(error, LegacyMigrationError::Cancelled) { + session.status = MigrationRunStatus::Cancelled; + } + self.finish_error_locked(&mut session, &error); + } + } + } + + fn finish_cancelled_before_execution(&self, plan: &MigrationPlan) { + let mut session = self.lock(); + session.running = false; + session.status = MigrationRunStatus::Cancelled; + session.progress = Some(MigrationProgressEvent { + run_id: plan.run_id.clone(), + phase: MigrationPhase::Acquire, + processed: 0, + total: plan.steps.len() as u64, + safe_to_cancel: true, + code: "migration_cancelled".to_string(), + ..MigrationProgressEvent::default() + }); + session.error = Some(CommandError::from_legacy(&LegacyMigrationError::Cancelled)); + } + + fn record_progress(&self, progress: MigrationProgressEvent) { + let mut session = self.lock(); + session.status = status_for_phase(progress.phase); + session.progress = Some(progress); + } + + fn persist_prompt_choice(&self, choice: MigrationPromptChoice) -> LegacyMigrationResult<()> { + let session = self.lock(); + let store = MigrationOnboardingStore::new(session.roots.clone()); + let request = &session.request; + let source = session.source.as_ref(); + let has_report = session.report.is_some(); + store.update(|state| { + state.format_version = CURRENT_MIGRATION_FORMAT_VERSION; + if let Some(source) = source { + state.source_fingerprint = source.source_fingerprint.clone(); + state.detected_at_ms.get_or_insert_with(now_ms); + } + state.choice = choice; + state.last_prompted_version = Some(env!("CARGO_PKG_VERSION").to_string()); + state.run_id = Some(request.run_id.clone()); + state.handled_run_id = Some(request.run_id.clone()); + if has_report { + state.last_report_run_id = Some(request.run_id.clone()); + } + })?; + Ok(()) + } + + fn restart_desktop(&self) -> LegacyMigrationResult<()> { + let run_id = self.lock().request.run_id.clone(); + let current = std::env::current_exe().map_err(|error| LegacyMigrationError::Io { + path: Path::new(DATA_MIGRATOR_BINARY_NAME).to_path_buf(), + source: error, + })?; + let executable = TrustedInstallationResolver::resolve_sibling( + ¤t, + DATA_MIGRATOR_BINARY_NAME, + DESKTOP_BINARY_NAME, + &PlatformExecutableTrustVerifier, + )?; + let arguments = [OsStr::new("--legacy-migration-run-id"), OsStr::new(&run_id)]; + launch_trusted_executable(&executable, &arguments)?; + Ok(()) + } + + fn finish_error_locked( + &self, + session: &mut MigratorSession, + error: &LegacyMigrationError, + ) -> CommandError { + session.running = false; + if matches!(error, LegacyMigrationError::Cancelled) { + session.status = MigrationRunStatus::Cancelled; + if let Some(progress) = &mut session.progress { + progress.safe_to_cancel = true; + progress.code = "migration_cancelled".to_string(); + } + } + let command_error = CommandError::from_legacy(error); + session.error = Some(command_error.clone()); + command_error + } + + fn lock(&self) -> MutexGuard<'_, MigratorSession> { + self.session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +fn migration_engine( + roots: MigrationRoots, + selection: &MigrationSelection, +) -> LegacyMigrationResult { + MigrationEngine::new(roots, adapters_for_groups(selection)) +} + +fn writer_processes(caller_process_id: u32) -> LegacyMigrationResult> { + blocking_writer_processes_for_product(caller_process_id, &[DESKTOP_BINARY_NAME]) +} + +fn validate_selection( + request: &MigratorHandoffRequest, + selection: &MigrationSelection, +) -> Result<(), CommandError> { + if selection.groups.is_empty() { + return Err(CommandError::new( + "empty_selection", + "Select at least one migration group.", + true, + )); + } + if request.mode == MigratorRequestMode::Execute && request.selection != *selection { + return Err(CommandError::new( + "selection_mismatch", + "The selected groups differ from the scope confirmed in OpenBitFun.", + false, + )); + } + Ok(()) +} + +fn probe_bound_source( + roots: &MigrationRoots, + request: &MigratorHandoffRequest, +) -> LegacyMigrationResult> { + let source = probe_legacy_source(roots, ProbeLimits::default())?; + if let Some(source) = &source { + if request + .source_id + .as_deref() + .is_some_and(|source_id| source_id != source.source_id) + || request + .source_fingerprint + .as_deref() + .is_some_and(|fingerprint| fingerprint != source.source_fingerprint) + { + return Err(LegacyMigrationError::InvalidRequest( + "discovered source does not match the authenticated handoff".to_string(), + )); + } + } else if request.source_id.is_some() || request.source_fingerprint.is_some() { + return Err(LegacyMigrationError::InvalidRequest( + "authenticated handoff source is no longer present".to_string(), + )); + } + Ok(source) +} + +fn snapshot(session: &MigratorSession) -> MigratorView { + let plan = session.plan.as_ref().map(redact_plan_for_ui); + let report = session.report.as_ref().map(redact_report_for_ui); + MigratorView { + delivery_profile: DeliveryProfile::DataMigrator.id().to_string(), + protocol: MigratorProtocolCapabilities::current(), + mode: session.request.mode, + source: session.source.clone(), + selection: session.selection.clone(), + findings: session + .findings + .iter() + .cloned() + .map(redact_finding_for_ui) + .collect(), + can_execute: plan.is_some() + && session + .source + .as_ref() + .is_some_and(|source| source.supported) + && !session.running, + plan, + report, + progress: session.progress.clone(), + blockers: session.blockers.clone(), + status: session.status, + running: session.running, + recovery: session.disposition == HandoffDisposition::Recovery, + error: session.error.clone(), + } +} + +fn redact_finding_for_ui(mut finding: ScanFinding) -> ScanFinding { + finding.detail = finding.code.replace('_', " "); + finding +} + +fn redact_plan_for_ui(plan: &MigrationPlan) -> MigrationPlan { + let mut redacted = plan.clone(); + redacted.findings = redacted + .findings + .into_iter() + .map(redact_finding_for_ui) + .collect(); + for conflict in &mut redacted.conflicts { + conflict.source_summary = "Legacy item".to_string(); + conflict.target_summary = "Existing OpenBitFun item".to_string(); + } + redacted +} + +fn redact_report_for_ui(report: &MigrationRunReport) -> MigrationRunReport { + let mut redacted = report.clone(); + for diagnostic in &mut redacted.diagnostics { + diagnostic.relative_path = None; + diagnostic.message = diagnostic.code.replace('_', " "); + diagnostic.action = None; + } + for result in &mut redacted.domain_results { + for warning in &mut result.warnings { + warning.relative_path = None; + warning.message = warning.code.replace('_', " "); + warning.action = None; + } + } + redacted +} + +fn status_for_phase(phase: MigrationPhase) -> MigrationRunStatus { + match phase { + MigrationPhase::Discover => MigrationRunStatus::Discovered, + MigrationPhase::Scan => MigrationRunStatus::Scanned, + MigrationPhase::Plan => MigrationRunStatus::Planned, + MigrationPhase::Acquire => MigrationRunStatus::WaitingForProcesses, + MigrationPhase::Stage => MigrationRunStatus::Staging, + MigrationPhase::ValidateStage => MigrationRunStatus::ValidatingStage, + MigrationPhase::Commit => MigrationRunStatus::Committing, + MigrationPhase::ValidateCommit => MigrationRunStatus::ValidatingCommit, + MigrationPhase::Finalize => MigrationRunStatus::Completed, + } +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use openbitfun_product_domains::legacy_migration::{ + MigratorProtocolCapability, MigratorRequestOrigin, CURRENT_MIGRATOR_PROTOCOL_VERSION, + }; + use std::collections::BTreeSet; + use std::fs; + + fn fixture_roots(root: &Path) -> MigrationRoots { + MigrationRoots { + legacy_user_root: root.join("legacy/user"), + legacy_home_root: root.join("legacy/home"), + legacy_skills_root: root.join("legacy/skills"), + legacy_ssh_root: root.join("legacy/ssh"), + target_user_root: root.join("target/user"), + target_home_root: root.join("target/home"), + target_skills_root: root.join("target/skills"), + target_ssh_root: root.join("target/ssh"), + } + } + + fn handoff_request() -> MigratorHandoffRequest { + let current = now_ms(); + MigratorHandoffRequest { + protocol_version: CURRENT_MIGRATOR_PROTOCOL_VERSION, + mode: MigratorRequestMode::Onboarding, + origin: MigratorRequestOrigin::FirstLaunch, + run_id: uuid::Uuid::new_v4().to_string(), + nonce: uuid::Uuid::new_v4().to_string(), + selection: MigrationSelection::all(), + caller_process_id: u32::MAX, + product_id: "openbitfun".to_string(), + release_channel: "stable".to_string(), + created_at_ms: current, + expires_at_ms: current + 60_000, + required_capabilities: BTreeSet::from([ + MigratorProtocolCapability::ReadOnlyScan, + MigratorProtocolCapability::JournalRecovery, + ]), + ..MigratorHandoffRequest::default() + } + } + + fn write_probe_fixture(roots: &MigrationRoots) { + let config = roots.legacy_user_root.join("config"); + fs::create_dir_all(&config).unwrap(); + fs::write(config.join("app.json"), br#"{"version":"0.2.19"}"#).unwrap(); + } + + #[test] + fn bootstrap_consumes_the_real_non_agent_delivery_profile() { + let temporary = tempfile::tempdir().unwrap(); + let roots = fixture_roots(temporary.path()); + write_probe_fixture(&roots); + let request = handoff_request(); + HandoffStore::new(roots.clone(), "openbitfun", "stable") + .write_request(&request, now_ms()) + .unwrap(); + + let coordinator = + MigratorCoordinator::bootstrap_with(&request.run_id, roots, "openbitfun", "stable") + .unwrap(); + let view = coordinator.snapshot(); + + assert_eq!(view.delivery_profile, "data-migrator"); + assert_eq!(view.mode, MigratorRequestMode::Onboarding); + assert!(view.source.is_some()); + assert!(!view.recovery); + } + + #[test] + fn execute_handoff_rejects_a_scope_change() { + let mut request = handoff_request(); + request.mode = MigratorRequestMode::Execute; + let mut changed = request.selection.clone(); + changed + .groups + .remove(&openbitfun_product_domains::legacy_migration::MigrationGroupId::Memory); + + let error = validate_selection(&request, &changed).unwrap_err(); + assert_eq!(error.code, "selection_mismatch"); + } + + #[test] + fn cancelled_scan_finishes_in_an_explicit_cancelled_state() { + let temporary = tempfile::tempdir().unwrap(); + let roots = fixture_roots(temporary.path()); + write_probe_fixture(&roots); + let request = handoff_request(); + HandoffStore::new(roots.clone(), "openbitfun", "stable") + .write_request(&request, now_ms()) + .unwrap(); + let coordinator = MigratorCoordinator::bootstrap_with( + &request.run_id, + roots.clone(), + "openbitfun", + "stable", + ) + .unwrap(); + let selection = MigrationSelection::all(); + let (roots, request, cancellation) = coordinator.begin_operation(&selection).unwrap(); + cancellation.cancel(); + coordinator.scan_background(roots, request, selection, cancellation); + + let view = coordinator.snapshot(); + assert!(!view.running); + assert_eq!(view.status, MigrationRunStatus::Cancelled); + assert_eq!( + view.error.map(|error| error.code).as_deref(), + Some("cancelled") + ); + assert_eq!( + view.progress.map(|progress| progress.code).as_deref(), + Some("migration_cancelled") + ); + } + + #[test] + fn command_errors_do_not_expose_storage_paths() { + let error = LegacyMigrationError::Io { + path: Path::new("C:/Users/private/secret.json").to_path_buf(), + source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "secret"), + }; + let command = CommandError::from_legacy(&error); + let serialized = serde_json::to_string(&command).unwrap(); + + assert_eq!(command.code, "storage_failed"); + assert!(!serialized.contains("private")); + assert!(!serialized.contains("secret")); + } +} diff --git a/src/apps/data-migrator/src/commands.rs b/src/apps/data-migrator/src/commands.rs new file mode 100644 index 0000000000..439a46f09f --- /dev/null +++ b/src/apps/data-migrator/src/commands.rs @@ -0,0 +1,88 @@ +use crate::app_state::{CommandError, MigratorCoordinator, MigratorView}; +use openbitfun_product_domains::legacy_migration::{MigrationPromptChoice, MigrationSelection}; +use serde::Deserialize; +use tauri::{AppHandle, State}; + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct EmptyRequest {} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct SelectionRequest { + pub selection: MigrationSelection, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ExecuteRequest { + pub plan_hash: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct PromptChoiceRequest { + pub choice: MigrationPromptChoice, +} + +#[tauri::command] +pub(crate) fn get_migrator_bootstrap( + state: State<'_, MigratorCoordinator>, + request: EmptyRequest, +) -> MigratorView { + let _ = request; + state.snapshot() +} + +#[tauri::command] +pub(crate) fn scan_legacy_migration( + state: State<'_, MigratorCoordinator>, + request: SelectionRequest, +) -> Result { + state.scan(request.selection) +} + +#[tauri::command] +pub(crate) fn prepare_legacy_migration( + state: State<'_, MigratorCoordinator>, + request: SelectionRequest, +) -> Result { + state.prepare(request.selection) +} + +#[tauri::command] +pub(crate) fn retry_writer_check( + state: State<'_, MigratorCoordinator>, + request: EmptyRequest, +) -> Result { + let _ = request; + state.refresh_blockers() +} + +#[tauri::command] +pub(crate) fn start_legacy_migration( + state: State<'_, MigratorCoordinator>, + request: ExecuteRequest, +) -> Result { + state.start(request.plan_hash) +} + +#[tauri::command] +pub(crate) fn cancel_legacy_migration( + state: State<'_, MigratorCoordinator>, + request: EmptyRequest, +) -> MigratorView { + let _ = request; + state.cancel() +} + +#[tauri::command] +pub(crate) fn finish_legacy_migration( + app: AppHandle, + state: State<'_, MigratorCoordinator>, + request: PromptChoiceRequest, +) -> Result<(), CommandError> { + state.finish_and_restart(request.choice)?; + app.exit(0); + Ok(()) +} diff --git a/src/apps/data-migrator/src/lib.rs b/src/apps/data-migrator/src/lib.rs new file mode 100644 index 0000000000..2b141603d3 --- /dev/null +++ b/src/apps/data-migrator/src/lib.rs @@ -0,0 +1,53 @@ +mod app_state; +mod commands; + +use app_state::MigratorCoordinator; +use std::fmt; +use tauri::Manager; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunError { + Bootstrap, + EventLoop, +} + +impl fmt::Display for RunError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bootstrap => formatter.write_str("Data Migrator bootstrap failed"), + Self::EventLoop => formatter.write_str("Data Migrator event loop failed"), + } + } +} + +impl std::error::Error for RunError {} + +pub fn run(run_id: &str) -> Result<(), RunError> { + let coordinator = MigratorCoordinator::bootstrap(run_id).map_err(|_| RunError::Bootstrap)?; + tauri::Builder::default() + .manage(coordinator) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + let coordinator = window.app_handle().state::(); + api.prevent_close(); + if coordinator.is_running() { + coordinator.cancel(); + return; + } + if coordinator.close_and_restart().is_ok() { + window.app_handle().exit(0); + } + } + }) + .invoke_handler(tauri::generate_handler![ + commands::get_migrator_bootstrap, + commands::scan_legacy_migration, + commands::prepare_legacy_migration, + commands::retry_writer_check, + commands::start_legacy_migration, + commands::cancel_legacy_migration, + commands::finish_legacy_migration, + ]) + .run(tauri::generate_context!()) + .map_err(|_| RunError::EventLoop) +} diff --git a/src/apps/data-migrator/src/main.rs b/src/apps/data-migrator/src/main.rs new file mode 100644 index 0000000000..d73be4545e --- /dev/null +++ b/src/apps/data-migrator/src/main.rs @@ -0,0 +1,21 @@ +#![cfg_attr(target_os = "windows", windows_subsystem = "windows")] + +use std::process::ExitCode; + +fn main() -> ExitCode { + let mut arguments = std::env::args_os().skip(1); + let Some(run_id) = arguments.next() else { + return ExitCode::from(2); + }; + if arguments.next().is_some() { + return ExitCode::from(2); + } + let Some(run_id) = run_id.to_str() else { + return ExitCode::from(2); + }; + + match openbitfun_data_migrator_lib::run(run_id) { + Ok(()) => ExitCode::SUCCESS, + Err(_) => ExitCode::from(1), + } +} diff --git a/src/apps/data-migrator/tauri.conf.json b/src/apps/data-migrator/tauri.conf.json new file mode 100644 index 0000000000..c7b678c1c6 --- /dev/null +++ b/src/apps/data-migrator/tauri.conf.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "OpenBitFun Data Migrator", + "mainBinaryName": "openbitfun-data-migrator", + "identifier": "com.openbitfun.data-migrator", + "build": { + "frontendDist": "ui" + }, + "bundle": { + "active": true, + "targets": "all", + "publisher": "OpenBitFun Team", + "icon": [ + "../desktop/icons/openbitfun-app-icon.icns", + "../desktop/icons/openbitfun-app-icon.ico", + "../desktop/icons/openbitfun-app-icon.png" + ], + "resources": { + "../../../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md" + } + }, + "app": { + "windows": [ + { + "label": "migrator", + "title": "OpenBitFun Data Migrator", + "width": 860, + "height": 680, + "minWidth": 680, + "minHeight": 540, + "resizable": true, + "maximizable": true, + "center": true + } + ], + "security": { + "csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost" + }, + "withGlobalTauri": true + } +} diff --git a/src/apps/data-migrator/ui/app.js b/src/apps/data-migrator/ui/app.js new file mode 100644 index 0000000000..97e55fc2d6 --- /dev/null +++ b/src/apps/data-migrator/ui/app.js @@ -0,0 +1,273 @@ +const invoke = window.__TAURI__.core.invoke; + +const translations = { + en: { + eyebrow: 'OpenBitFun maintenance', title: 'Import data from BitFun', + intro: 'Choose what to bring forward. Your original BitFun data will not be deleted.', + stepSource: 'Step 1', sourceTitle: 'Legacy source', firstLaunch: 'First launch', + choiceTitle: 'What would you like to do?', + choiceHelp: 'Migration runs only after OpenBitFun and other data writers have stopped.', + migrateNow: 'Migrate now', remindLater: 'Remind me later', doNotRemind: 'Do not remind me', + stepScope: 'Step 2', scopeTitle: 'Choose migration scope', scan: 'Scan selected data', + stepReview: 'Step 3', reviewTitle: 'Review scan', prepare: 'Run preflight plan', + stepConfirm: 'Step 4', planTitle: 'Confirm migration', retryWriters: 'Check processes again', + start: 'Start migration', stepProgress: 'Step 5', progressTitle: 'Migration progress', + phase: 'Phase', domain: 'Domain', count: 'Completed steps', + cancel: 'Cancel at a safe boundary', stepDone: 'Result', reportTitle: 'Migration report', + reportPrivacy: 'This summary contains counts and result codes, not credentials or user content.', + openDesktop: 'Open OpenBitFun', ready: 'Ready', unsupported: 'Unsupported', missing: 'Not found', + sourceFound: 'BitFun {version} was found. The source stays read-only.', + recovery: 'A previous migration journal was found and can be resumed.', + blockers: '{count} data-writing process(es) must stop before migration can continue.', + noBlockers: 'No data-writing processes are blocking migration.', + steps: '{count} ordered domain step(s)', conflicts: '{count} visible conflict(s)', + imported: 'imported', skipped: 'skipped', warnings: 'warnings', + }, + 'zh-CN': { + eyebrow: 'OpenBitFun 数据维护', title: '从 BitFun 导入数据', + intro: '选择要迁移的内容。原始 BitFun 数据不会被删除。', stepSource: '第 1 步', + sourceTitle: '旧版数据来源', firstLaunch: '首次启动', choiceTitle: '你希望如何处理?', + choiceHelp: '迁移只会在 OpenBitFun 和其他数据写入进程停止后运行。', migrateNow: '立即迁移', + remindLater: '稍后提醒', doNotRemind: '不再提醒', stepScope: '第 2 步', + scopeTitle: '选择迁移范围', scan: '扫描所选数据', stepReview: '第 3 步', + reviewTitle: '检查扫描结果', prepare: '运行迁移预检', stepConfirm: '第 4 步', + planTitle: '确认迁移', retryWriters: '重新检查进程', start: '开始迁移', + stepProgress: '第 5 步', progressTitle: '迁移进度', phase: '阶段', domain: '领域', + count: '已完成步骤', cancel: '在安全边界取消', stepDone: '结果', reportTitle: '迁移报告', + reportPrivacy: '此摘要仅包含计数和结果码,不包含凭据或用户正文。', openDesktop: '打开 OpenBitFun', + ready: '可迁移', unsupported: '不受支持', missing: '未发现', + sourceFound: '已发现 BitFun {version}。迁移期间来源保持只读。', + recovery: '发现上次迁移日志,可以从安全状态继续。', + blockers: '迁移前还需停止 {count} 个数据写入进程。', noBlockers: '没有进程阻止迁移。', + steps: '{count} 个有序领域步骤', conflicts: '{count} 个可见冲突', + imported: '已导入', skipped: '已跳过', warnings: '警告', + }, + 'zh-TW': { + eyebrow: 'OpenBitFun 資料維護', title: '從 BitFun 匯入資料', + intro: '選擇要遷移的內容。原始 BitFun 資料不會被刪除。', stepSource: '第 1 步', + sourceTitle: '舊版資料來源', firstLaunch: '首次啟動', choiceTitle: '你希望如何處理?', + choiceHelp: '遷移只會在 OpenBitFun 和其他資料寫入程序停止後執行。', migrateNow: '立即遷移', + remindLater: '稍後提醒', doNotRemind: '不再提醒', stepScope: '第 2 步', + scopeTitle: '選擇遷移範圍', scan: '掃描所選資料', stepReview: '第 3 步', + reviewTitle: '檢查掃描結果', prepare: '執行遷移預檢', stepConfirm: '第 4 步', + planTitle: '確認遷移', retryWriters: '重新檢查程序', start: '開始遷移', + stepProgress: '第 5 步', progressTitle: '遷移進度', phase: '階段', domain: '領域', + count: '已完成步驟', cancel: '在安全邊界取消', stepDone: '結果', reportTitle: '遷移報告', + reportPrivacy: '此摘要僅包含計數和結果碼,不包含憑據或使用者正文。', openDesktop: '開啟 OpenBitFun', + ready: '可遷移', unsupported: '不支援', missing: '未發現', + sourceFound: '已發現 BitFun {version}。遷移期間來源保持唯讀。', + recovery: '發現上次遷移日誌,可以從安全狀態繼續。', + blockers: '遷移前還需停止 {count} 個資料寫入程序。', noBlockers: '沒有程序阻止遷移。', + steps: '{count} 個有序領域步驟', conflicts: '{count} 個可見衝突', + imported: '已匯入', skipped: '已略過', warnings: '警告', + }, +}; + +const locale = navigator.language.startsWith('zh-TW') || navigator.language.startsWith('zh-HK') + ? 'zh-TW' + : navigator.language.startsWith('zh') ? 'zh-CN' : 'en'; +const text = translations[locale]; +document.documentElement.lang = locale; +document.querySelectorAll('[data-i18n]').forEach((node) => { + node.textContent = text[node.dataset.i18n] || translations.en[node.dataset.i18n]; +}); + +const groups = [ + ['settings_and_credentials', { + en: ['Settings and credentials', 'Settings are imported; credentials that cannot be decrypted are marked for sign-in.'], + 'zh-CN': ['设置与服务凭据', '导入设置;无法解密的凭据会标记为需要重新登录。'], + 'zh-TW': ['設定與服務憑據', '匯入設定;無法解密的憑據會標記為需要重新登入。'], + }], + ['agents_skills_and_miniapps', { + en: ['Agents, Skills, and MiniApps', 'User content is imported; system Skills and built-in MiniApps are excluded.'], + 'zh-CN': ['Agents、Skills 与 MiniApps', '导入用户内容;排除系统 Skills 和内置 MiniApps。'], + 'zh-TW': ['Agents、Skills 與 MiniApps', '匯入使用者內容;排除系統 Skills 和內建 MiniApps。'], + }], + ['workspaces_sessions_and_tasks', { + en: ['Workspaces, sessions, and tasks', 'Includes coordination.sqlite as a required atomic dependency.'], + 'zh-CN': ['工作区、会话与 Agent 任务状态', '强制包含 coordination.sqlite 作为原子依赖。'], + 'zh-TW': ['工作區、工作階段與 Agent 任務狀態', '強制包含 coordination.sqlite 作為原子相依項。'], + }], + ['memory', { + en: ['Memory', 'Imports structured and file-backed memory without user-content telemetry.'], + 'zh-CN': ['记忆', '导入结构化与文件记忆,不发送用户正文遥测。'], + 'zh-TW': ['記憶', '匯入結構化與檔案記憶,不傳送使用者正文遙測。'], + }], + ['remote_connections_and_devices', { + en: ['Remote connections and devices', 'Non-portable identities and secrets are marked for repair or sign-in.'], + 'zh-CN': ['远程连接与设备', '不可移植的身份和秘密会标记为需要修复或重新登录。'], + 'zh-TW': ['遠端連線與裝置', '不可移植的身分和秘密會標記為需要修復或重新登入。'], + }], +]; + +let current; +let pollTimer; + +function format(template, values) { + return Object.entries(values).reduce((value, [key, replacement]) => + value.replace(`{${key}}`, String(replacement)), template); +} + +function show(id, visible = true) { + document.getElementById(id).hidden = !visible; +} + +function setBusy(busy) { + document.querySelectorAll('button').forEach((button) => { button.disabled = busy; }); +} + +function notice(message) { + const node = document.getElementById('notice'); + node.textContent = message || ''; + node.hidden = !message; +} + +function row(title, detail) { + const item = document.createElement('div'); + item.className = 'result-row'; + const strong = document.createElement('strong'); + strong.textContent = title; + const small = document.createElement('small'); + small.textContent = detail; + item.append(strong, small); + return item; +} + +function renderScopes(selection) { + const list = document.getElementById('scope-list'); + list.replaceChildren(); + const selected = new Set(selection?.groups?.length ? selection.groups : groups.map(([id]) => id)); + groups.forEach(([id, labels]) => { + const option = document.createElement('div'); + option.className = 'scope-option'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = `scope-${id}`; + checkbox.value = id; + checkbox.checked = selected.has(id); + if (current?.mode === 'execute') checkbox.disabled = true; + const label = document.createElement('label'); + label.htmlFor = checkbox.id; + const strong = document.createElement('strong'); + const description = document.createElement('span'); + [strong.textContent, description.textContent] = labels[locale] || labels.en; + label.append(strong, description); + option.append(checkbox, label); + list.append(option); + }); +} + +function selection() { + return { + groups: [...document.querySelectorAll('#scope-list input:checked')].map((input) => input.value), + }; +} + +function render(view) { + current = view; + const source = view.source; + document.getElementById('source-badge').textContent = !source + ? text.missing : source.supported ? text.ready : text.unsupported; + document.getElementById('source-summary').textContent = source + ? format(text.sourceFound, { version: source.productVersion }) : text.missing; + document.getElementById('source-path').textContent = source?.roots?.[0]?.displayPath || ''; + notice(view.error?.message || (view.recovery ? text.recovery : '')); + + show('choice-card', view.mode === 'onboarding' && !view.findings.length && !view.plan && !view.running); + show('scope-card', Boolean(source) && (view.mode === 'execute' || view.findings.length || view.plan)); + renderScopes(view.selection); + + const findings = document.getElementById('findings'); + findings.replaceChildren(...view.findings.map((finding) => + row(finding.code, `${finding.entityCount} item(s), ${finding.logicalBytes} byte(s)`))); + show('scan-card', view.findings.length > 0 && !view.plan); + + const planSummary = document.getElementById('plan-summary'); + if (view.plan) { + planSummary.replaceChildren( + row(text.steps.replace('{count}', view.plan.steps.length), view.plan.planHash), + row(text.conflicts.replace('{count}', view.plan.conflicts.length), `${view.plan.estimatedWriteBytes} byte(s)`), + ); + } + show('plan-card', Boolean(view.plan) && !view.running && !view.report); + const blocker = document.getElementById('blockers'); + blocker.textContent = view.blockers.length + ? format(text.blockers, { count: view.blockers.length }) : text.noBlockers; + blocker.hidden = !view.blockers.length; + show('retry-writers', view.blockers.length > 0); + + const progress = view.progress; + show('progress-card', Boolean(progress) && (view.running || view.status === 'cancelled')); + if (progress) { + document.getElementById('phase').textContent = progress.phase; + document.getElementById('domain').textContent = progress.domain || '-'; + document.getElementById('count').textContent = `${progress.processed} / ${progress.total}`; + document.getElementById('progress-message').textContent = progress.code.replaceAll('_', ' '); + document.getElementById('cancel').disabled = !view.running; + } + + const reportSummary = document.getElementById('report-summary'); + if (view.report) { + reportSummary.replaceChildren(...view.report.domainResults.map((result) => + row(result.domain, `${result.imported} ${text.imported}, ${result.skipped} ${text.skipped}, ${result.warnings.length} ${text.warnings}`))); + } + show('report-card', !view.running && (Boolean(view.report) || view.status === 'cancelled')); + document.getElementById('start').disabled = !view.canExecute; + + if (view.running && !pollTimer) { + pollTimer = window.setInterval(refresh, 500); + } else if (!view.running && pollTimer) { + window.clearInterval(pollTimer); + pollTimer = undefined; + } +} + +async function call(command, request = {}) { + setBusy(true); + try { + const result = await invoke(command, { request }); + if (result) render(result); + return result; + } catch (error) { + notice(error?.message || String(error)); + return undefined; + } finally { + setBusy(false); + if (current) render(current); + } +} + +async function refresh() { + try { + render(await invoke('get_migrator_bootstrap', { request: {} })); + } catch { + if (pollTimer) window.clearInterval(pollTimer); + pollTimer = undefined; + } +} + +document.getElementById('migrate-now').addEventListener('click', () => { + show('choice-card', false); + show('scope-card'); + renderScopes(current.selection); +}); +document.getElementById('remind-later').addEventListener('click', () => + call('finish_legacy_migration', { choice: 'remind_later' })); +document.getElementById('do-not-remind').addEventListener('click', () => + call('finish_legacy_migration', { choice: 'do_not_remind' })); +document.getElementById('scan').addEventListener('click', () => + call('scan_legacy_migration', { selection: selection() })); +document.getElementById('prepare').addEventListener('click', () => + call('prepare_legacy_migration', { selection: selection() })); +document.getElementById('retry-writers').addEventListener('click', () => + call('retry_writer_check')); +document.getElementById('start').addEventListener('click', () => + call('start_legacy_migration', { planHash: current.plan.planHash })); +document.getElementById('cancel').addEventListener('click', () => + call('cancel_legacy_migration')); +document.getElementById('open-desktop').addEventListener('click', () => + call('finish_legacy_migration', { choice: current.report ? 'migrate_now' : 'remind_later' })); + +refresh().then(() => { + if (current?.mode === 'execute') show('scope-card'); +}); diff --git a/src/apps/data-migrator/ui/index.html b/src/apps/data-migrator/ui/index.html new file mode 100644 index 0000000000..be9dbbff8e --- /dev/null +++ b/src/apps/data-migrator/ui/index.html @@ -0,0 +1,103 @@ + + + + + + OpenBitFun Data Migrator + + + + +
+
+

OpenBitFun maintenance

+

Import data from BitFun

+

+ Choose what to bring forward. Your original BitFun data will not be deleted. +

+
+ + + +
+
+
+

Step 1

+

Legacy source

+
+ Checking +
+

+

+
+ +
+

First launch

+

What would you like to do?

+

+ Migration runs only after OpenBitFun and other data writers have stopped. +

+
+ + + +
+
+ + + + + + + + + + +
+ + diff --git a/src/apps/data-migrator/ui/styles.css b/src/apps/data-migrator/ui/styles.css new file mode 100644 index 0000000000..226d1f27ee --- /dev/null +++ b/src/apps/data-migrator/ui/styles.css @@ -0,0 +1,224 @@ +:root { + color-scheme: light dark; + font-family: Inter, "Segoe UI", system-ui, sans-serif; + --surface: Canvas; + --surface-raised: color-mix(in srgb, Canvas 94%, CanvasText 6%); + --text: CanvasText; + --muted: color-mix(in srgb, CanvasText 64%, transparent); + --border: color-mix(in srgb, CanvasText 18%, transparent); + --accent: AccentColor; + --accent-text: AccentColorText; + --warning: color-mix(in srgb, Mark 18%, Canvas); + --focus: AccentColor; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--surface); + color: var(--text); +} + +.shell { + width: min(760px, calc(100% - 40px)); + margin: 0 auto; + padding: 44px 0 56px; +} + +header { + margin-bottom: 26px; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 10px; + font-size: clamp(28px, 5vw, 42px); + line-height: 1.1; +} + +h2 { + margin-bottom: 8px; + font-size: 20px; +} + +.eyebrow, +.step { + margin-bottom: 8px; + color: var(--accent); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.lead, +.muted { + color: var(--muted); + line-height: 1.55; +} + +.card, +.notice { + margin-bottom: 16px; + padding: 22px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface-raised); +} + +.notice { + border-color: var(--accent); +} + +.card-heading, +.result-row, +.scope-option { + display: flex; + justify-content: space-between; + gap: 16px; +} + +.badge { + align-self: flex-start; + padding: 5px 9px; + border-radius: 999px; + background: var(--border); + font-size: 12px; + font-weight: 700; +} + +.path { + overflow-wrap: anywhere; + color: var(--muted); + font-family: ui-monospace, "Cascadia Mono", monospace; + font-size: 12px; +} + +.scope-list, +.result-list { + display: grid; + gap: 10px; + margin: 18px 0; +} + +.scope-option, +.result-row { + padding: 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); +} + +.scope-option label { + flex: 1; + cursor: pointer; +} + +.scope-option strong, +.scope-option span { + display: block; +} + +.scope-option span, +.result-row small { + margin-top: 4px; + color: var(--muted); + line-height: 1.4; +} + +input[type="checkbox"] { + width: 20px; + height: 20px; + accent-color: var(--accent); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 18px; +} + +button { + min-height: 40px; + padding: 9px 15px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface); + color: var(--text); + cursor: pointer; + font: inherit; + font-weight: 650; +} + +button.primary { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-text); +} + +button.quiet { + border-color: transparent; + background: transparent; +} + +button:disabled { + cursor: wait; + opacity: 0.55; +} + +button:focus-visible, +input:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +.warning { + margin-top: 14px; + padding: 14px; + border-radius: 10px; + background: var(--warning); + line-height: 1.5; +} + +.progress-facts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.progress-facts div { + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; +} + +.progress-facts dt { + color: var(--muted); + font-size: 12px; +} + +.progress-facts dd { + margin: 6px 0 0; + overflow-wrap: anywhere; + font-weight: 700; +} + +@media (max-width: 680px) { + .shell { + width: min(100% - 24px, 760px); + padding-top: 24px; + } + + .progress-facts { + grid-template-columns: 1fr; + } +} diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 634c939780..8b9279c212 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -23,7 +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-product-domains = { path = "../../crates/contracts/product-domains", features = ["appearance-market", "legacy-migration"] } +openbitfun-legacy-migration = { path = "../../crates/services/legacy-migration" } openbitfun-services-integrations = { path = "../../crates/services/services-integrations", features = ["canvas-runtime", "miniapp-market", "remote-ssh-concrete", "speech-realtime"] } openbitfun-core-types = { path = "../../crates/contracts/core-types" } openbitfun-agent-tools = { path = "../../crates/execution/tool-contracts", features = ["element-token"] } @@ -33,7 +34,7 @@ openbitfun-webdriver = { path = "../../crates/adapters/webdriver" } openbitfun-acp = { path = "../../crates/interfaces/acp", default-features = false, features = ["client"] } # Tauri -tauri = { workspace = true } +tauri = { workspace = true, features = ["unstable", "macos-private-api", "tray-icon"] } tauri-plugin-opener = { workspace = true } tauri-plugin-dialog = { workspace = true } tauri-plugin-fs = { workspace = true } diff --git a/src/apps/desktop/build.rs b/src/apps/desktop/build.rs index b602fe0cbf..67ac4082d4 100644 --- a/src/apps/desktop/build.rs +++ b/src/apps/desktop/build.rs @@ -1,5 +1,7 @@ fn main() { println!("cargo:rerun-if-env-changed=OPENBITFUN_RELEASE_CHANNEL"); + println!("cargo:rerun-if-env-changed=OPENBITFUN_DESKTOP_BINARY_NAME"); + println!("cargo:rerun-if-env-changed=OPENBITFUN_DATA_MIGRATOR_BINARY_NAME"); println!("cargo:rerun-if-env-changed=OPENBITFUN_UPDATER_PRIMARY_ENDPOINT"); println!("cargo:rerun-if-env-changed=OPENBITFUN_UPDATER_FALLBACK_ENDPOINT"); // The Windows primary thread keeps the Tauri event loop and native window diff --git a/src/apps/desktop/src/api/legacy_migration_api.rs b/src/apps/desktop/src/api/legacy_migration_api.rs new file mode 100644 index 0000000000..fe23353643 --- /dev/null +++ b/src/apps/desktop/src/api/legacy_migration_api.rs @@ -0,0 +1,620 @@ +//! Local-only product entry points for the standalone legacy Data Migrator. +//! +//! This module may inspect the retired BitFun roots and create authenticated +//! handoffs. It never writes migrated product data; only the sibling Data +//! Migrator executes a plan after Desktop has shut down. + +use openbitfun_core::legacy_migration::adapters_for_groups; +use openbitfun_core_types::product_identity::product_id; +use openbitfun_legacy_migration::{ + launch_trusted_executable, probe_legacy_source, CancellationToken, HandoffStore, + LegacyMigrationError, LegacyMigrationResult, MigrationEngine, MigrationOnboardingStore, + MigrationRoots, PlatformExecutableTrustVerifier, ProbeLimits, TrustedInstallationResolver, +}; +use openbitfun_product_domains::legacy_migration::{ + LegacySourceDescriptor, MigrationOnboardingState, MigrationPromptChoice, MigrationRunReport, + MigrationSelection, MigratorHandoffRequest, MigratorProtocolCapabilities, MigratorRequestMode, + MigratorRequestOrigin, ScanFinding, CURRENT_MIGRATION_FORMAT_VERSION, +}; +use serde::{Deserialize, Serialize}; +use std::ffi::{OsStr, OsString}; +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; + +const RELEASE_CHANNEL: &str = match option_env!("OPENBITFUN_RELEASE_CHANNEL") { + Some(value) => value, + None => "stable", +}; +const DESKTOP_BINARY_NAME: &str = match option_env!("OPENBITFUN_DESKTOP_BINARY_NAME") { + Some(value) => value, + None => "openbitfun-desktop", +}; +const DATA_MIGRATOR_BINARY_NAME: &str = match option_env!("OPENBITFUN_DATA_MIGRATOR_BINARY_NAME") { + Some(value) => value, + None => "openbitfun-data-migrator", +}; +const HANDOFF_LIFETIME_MS: i64 = 10 * 60 * 1000; + +static STARTUP_HANDLED_RUN_ID: OnceLock> = OnceLock::new(); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum StartupProbeDisposition { + Continue { handled_run_id: Option }, + MigratorLaunched, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyMigrationCommandError { + pub code: String, + pub message: String, + pub recoverable: bool, +} + +impl LegacyMigrationCommandError { + fn new(code: &str, message: &str, recoverable: bool) -> Self { + Self { + code: code.to_string(), + message: message.to_string(), + recoverable, + } + } + + fn from_legacy(error: &LegacyMigrationError) -> Self { + match error { + LegacyMigrationError::UnsupportedSource(_) => Self::new( + "unsupported_source", + "The discovered BitFun data format is not supported by this Data Migrator.", + false, + ), + LegacyMigrationError::InvalidRequest(_) | LegacyMigrationError::InvalidPlan(_) => { + Self::new( + "invalid_migration_request", + "The migration request is invalid or no longer current.", + true, + ) + } + LegacyMigrationError::UntrustedExecutable(_) + | LegacyMigrationError::TrustedInstallationUnavailable(_) => Self::new( + "data_migrator_unavailable", + "The signed Data Migrator sibling is missing or cannot be trusted. Repair or update this OpenBitFun installation.", + false, + ), + LegacyMigrationError::LockUnavailable => Self::new( + "migration_locked", + "Another Data Migrator currently owns the migration lock.", + true, + ), + LegacyMigrationError::PathUnavailable(_) + | LegacyMigrationError::SourceEqualsTarget(_) + | LegacyMigrationError::PathEscape(_) + | LegacyMigrationError::LinkedPath(_) + | LegacyMigrationError::ResourceLimit(_) => Self::new( + "migration_storage_unavailable", + "The local migration storage or retired BitFun source failed a safety check.", + false, + ), + LegacyMigrationError::ProcessInspection(_) => Self::new( + "process_inspection_failed", + "OpenBitFun could not inspect local data-writing processes.", + true, + ), + LegacyMigrationError::Cancelled => Self::new( + "cancelled", + "The read-only migration operation was cancelled.", + true, + ), + LegacyMigrationError::InjectedCrash(_) + | LegacyMigrationError::Domain { .. } + | LegacyMigrationError::Io { .. } + | LegacyMigrationError::Json { .. } + | LegacyMigrationError::Sqlite { .. } => Self::new( + "migration_operation_failed", + "OpenBitFun could not safely inspect or prepare the local migration state.", + true, + ), + } + } +} + +impl From for LegacyMigrationCommandError { + fn from(error: LegacyMigrationError) -> Self { + Self::from_legacy(&error) + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields, rename_all = "camelCase")] +pub struct EmptyLegacyMigrationRequest {} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields, rename_all = "camelCase")] +pub struct ScanLegacyMigrationRequest { + pub selection: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PrepareLegacyMigrationRequest { + pub selection: MigrationSelection, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields, rename_all = "camelCase")] +pub struct GetLegacyMigrationReportRequest { + pub run_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SetLegacyMigrationPromptPreferenceRequest { + pub choice: MigrationPromptChoice, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyMigrationStatusView { + pub source: Option, + pub onboarding: MigrationOnboardingState, + pub latest_report: Option, + pub startup_report: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyMigrationScanView { + pub source: LegacySourceDescriptor, + pub selection: MigrationSelection, + pub scanned_at_ms: i64, + pub findings: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyMigrationHandoffView { + pub run_id: String, + pub mode: MigratorRequestMode, +} + +pub(crate) fn run_startup_probe() -> LegacyMigrationResult { + let roots = MigrationRoots::resolve_current_user()?; + let onboarding = MigrationOnboardingStore::new(roots.clone()); + let restart_run_id = migration_restart_run_id(std::env::args_os().skip(1)); + if let Some(run_id) = restart_run_id { + if onboarding.consume_handled_run_id(&run_id)? { + return Ok(StartupProbeDisposition::Continue { + handled_run_id: Some(run_id), + }); + } + } + + let state = onboarding.load()?; + let Some(source) = probe_legacy_source(&roots, ProbeLimits::default())? else { + return Ok(StartupProbeDisposition::Continue { + handled_run_id: None, + }); + }; + if !should_offer_onboarding(&state, &source) { + return Ok(StartupProbeDisposition::Continue { + handled_run_id: None, + }); + } + + let request = new_handoff_request( + MigratorRequestMode::Onboarding, + MigratorRequestOrigin::FirstLaunch, + &source, + MigrationSelection::all(), + ); + HandoffStore::new(roots.clone(), product_id(), RELEASE_CHANNEL) + .write_request(&request, now_ms())?; + onboarding.update(|state| { + state.format_version = CURRENT_MIGRATION_FORMAT_VERSION; + if state.source_fingerprint != source.source_fingerprint { + state.choice = MigrationPromptChoice::Unset; + } + state.source_fingerprint = source.source_fingerprint.clone(); + state.detected_at_ms.get_or_insert_with(now_ms); + state.last_prompted_version = Some(env!("CARGO_PKG_VERSION").to_string()); + state.run_id = Some(request.run_id.clone()); + state.handled_run_id = None; + })?; + launch_data_migrator(&request.run_id)?; + Ok(StartupProbeDisposition::MigratorLaunched) +} + +pub(crate) fn set_startup_handled_run_id(run_id: Option) { + let _ = STARTUP_HANDLED_RUN_ID.set(run_id); +} + +#[tauri::command] +pub fn get_legacy_migration_status( + request: EmptyLegacyMigrationRequest, +) -> Result { + let _ = request; + migration_status().map_err(Into::into) +} + +#[tauri::command] +pub async fn scan_legacy_migration( + request: ScanLegacyMigrationRequest, +) -> Result { + let selection = request.selection.unwrap_or_else(MigrationSelection::all); + if selection.groups.is_empty() { + return Err(LegacyMigrationCommandError::new( + "empty_selection", + "Select at least one migration group.", + true, + )); + } + tauri::async_runtime::spawn_blocking(move || scan_local_source(selection)) + .await + .map_err(|_| { + LegacyMigrationCommandError::new( + "scan_worker_failed", + "The local read-only migration scan stopped unexpectedly.", + true, + ) + })? + .map_err(Into::into) +} + +#[tauri::command] +pub fn prepare_legacy_migration( + app: AppHandle, + request: PrepareLegacyMigrationRequest, +) -> Result { + if request.selection.groups.is_empty() { + return Err(LegacyMigrationCommandError::new( + "empty_selection", + "Select at least one migration group.", + true, + )); + } + let handoff = + prepare_settings_handoff(request.selection).map_err(LegacyMigrationCommandError::from)?; + crate::request_desktop_exit(&app, 0, "legacy_migration_handoff"); + Ok(handoff) +} + +#[tauri::command] +pub fn get_legacy_migration_report( + request: GetLegacyMigrationReportRequest, +) -> Result, LegacyMigrationCommandError> { + let roots = + MigrationRoots::resolve_current_user().map_err(LegacyMigrationCommandError::from)?; + let store = MigrationOnboardingStore::new(roots); + let report = match request.run_id.as_deref() { + Some(run_id) => store.load_report(run_id), + None => store.load_last_report(), + } + .map_err(LegacyMigrationCommandError::from)?; + Ok(report.as_ref().map(redact_report_for_ui)) +} + +#[tauri::command] +pub fn set_legacy_migration_prompt_preference( + request: SetLegacyMigrationPromptPreferenceRequest, +) -> Result { + if !matches!( + request.choice, + MigrationPromptChoice::Unset + | MigrationPromptChoice::RemindLater + | MigrationPromptChoice::DoNotRemind + ) { + return Err(LegacyMigrationCommandError::new( + "invalid_prompt_preference", + "Only reminder preferences can be changed from OpenBitFun settings.", + false, + )); + } + let roots = + MigrationRoots::resolve_current_user().map_err(LegacyMigrationCommandError::from)?; + MigrationOnboardingStore::new(roots) + .update(|state| { + state.format_version = CURRENT_MIGRATION_FORMAT_VERSION; + state.choice = request.choice; + state.handled_run_id = None; + }) + .map_err(Into::into) +} + +fn migration_status() -> LegacyMigrationResult { + let roots = MigrationRoots::resolve_current_user()?; + let onboarding_store = MigrationOnboardingStore::new(roots.clone()); + let onboarding = onboarding_store.load()?; + let source = probe_legacy_source(&roots, ProbeLimits::default())?; + let latest_report = onboarding_store + .load_last_report()? + .as_ref() + .map(redact_report_for_ui); + let startup_report = STARTUP_HANDLED_RUN_ID + .get() + .and_then(|run_id| run_id.as_deref()) + .map(|run_id| onboarding_store.load_report(run_id)) + .transpose()? + .flatten() + .as_ref() + .map(redact_report_for_ui); + Ok(LegacyMigrationStatusView { + source, + onboarding, + latest_report, + startup_report, + }) +} + +fn scan_local_source( + selection: MigrationSelection, +) -> LegacyMigrationResult { + let roots = MigrationRoots::resolve_current_user()?; + let source = probe_legacy_source(&roots, ProbeLimits::default())?.ok_or_else(|| { + LegacyMigrationError::UnsupportedSource( + "no supported legacy BitFun data was discovered".to_string(), + ) + })?; + if !source.supported { + return Err(LegacyMigrationError::UnsupportedSource( + source.product_version.clone(), + )); + } + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection))?; + let findings = engine + .scan(&selection, &CancellationToken::default())? + .into_iter() + .map(|scan| scan.finding) + .collect(); + let scanned_at_ms = now_ms(); + MigrationOnboardingStore::new(roots).update(|state| { + state.format_version = CURRENT_MIGRATION_FORMAT_VERSION; + state.source_fingerprint = source.source_fingerprint.clone(); + state.detected_at_ms.get_or_insert(scanned_at_ms); + state.last_scanned_at_ms = Some(scanned_at_ms); + })?; + Ok(LegacyMigrationScanView { + source, + selection, + scanned_at_ms, + findings, + }) +} + +fn prepare_settings_handoff( + selection: MigrationSelection, +) -> LegacyMigrationResult { + let roots = MigrationRoots::resolve_current_user()?; + let source = probe_legacy_source(&roots, ProbeLimits::default())?.ok_or_else(|| { + LegacyMigrationError::UnsupportedSource( + "no supported legacy BitFun data was discovered".to_string(), + ) + })?; + if !source.supported { + return Err(LegacyMigrationError::UnsupportedSource( + source.product_version.clone(), + )); + } + let request = new_handoff_request( + MigratorRequestMode::Execute, + MigratorRequestOrigin::Settings, + &source, + selection, + ); + HandoffStore::new(roots.clone(), product_id(), RELEASE_CHANNEL) + .write_request(&request, now_ms())?; + MigrationOnboardingStore::new(roots).update(|state| { + state.format_version = CURRENT_MIGRATION_FORMAT_VERSION; + state.source_fingerprint = source.source_fingerprint.clone(); + state.detected_at_ms.get_or_insert_with(now_ms); + state.run_id = Some(request.run_id.clone()); + state.handled_run_id = None; + })?; + launch_data_migrator(&request.run_id)?; + Ok(LegacyMigrationHandoffView { + run_id: request.run_id, + mode: request.mode, + }) +} + +fn new_handoff_request( + mode: MigratorRequestMode, + origin: MigratorRequestOrigin, + source: &LegacySourceDescriptor, + selection: MigrationSelection, +) -> MigratorHandoffRequest { + let created_at_ms = now_ms(); + let capabilities = MigratorProtocolCapabilities::current(); + MigratorHandoffRequest { + protocol_version: capabilities.protocol_version, + mode, + origin, + run_id: uuid::Uuid::new_v4().to_string(), + nonce: uuid::Uuid::new_v4().to_string(), + source_id: Some(source.source_id.clone()), + source_fingerprint: Some(source.source_fingerprint.clone()), + selection, + caller_process_id: std::process::id(), + product_id: product_id().to_string(), + release_channel: RELEASE_CHANNEL.to_string(), + created_at_ms, + expires_at_ms: created_at_ms.saturating_add(HANDOFF_LIFETIME_MS), + required_capabilities: capabilities.capabilities, + } +} + +fn launch_data_migrator(run_id: &str) -> LegacyMigrationResult { + let current = std::env::current_exe().map_err(|error| LegacyMigrationError::Io { + path: DESKTOP_BINARY_NAME.into(), + source: error, + })?; + let executable = TrustedInstallationResolver::resolve_sibling( + ¤t, + DESKTOP_BINARY_NAME, + DATA_MIGRATOR_BINARY_NAME, + &PlatformExecutableTrustVerifier, + )?; + launch_trusted_executable(&executable, &[OsStr::new(run_id)]) +} + +fn should_offer_onboarding( + state: &MigrationOnboardingState, + source: &LegacySourceDescriptor, +) -> bool { + if !source.supported || source.already_migrated { + return false; + } + if state.source_fingerprint != source.source_fingerprint { + return true; + } + !matches!( + state.choice, + MigrationPromptChoice::DoNotRemind | MigrationPromptChoice::MigrateNow + ) +} + +fn migration_restart_run_id(arguments: impl IntoIterator) -> Option { + let mut values = arguments.into_iter(); + let mut run_id = None; + while let Some(argument) = values.next() { + if argument != OsStr::new("--legacy-migration-run-id") { + continue; + } + let candidate = values.next()?.into_string().ok()?; + if run_id.is_some() || uuid::Uuid::parse_str(&candidate).is_err() { + return None; + } + run_id = Some(candidate); + } + run_id +} + +fn redact_report_for_ui(report: &MigrationRunReport) -> MigrationRunReport { + let mut redacted = report.clone(); + for diagnostic in &mut redacted.diagnostics { + diagnostic.relative_path = None; + diagnostic.message = diagnostic.code.replace('_', " "); + diagnostic.action = None; + } + for result in &mut redacted.domain_results { + for warning in &mut result.warnings { + warning.relative_path = None; + warning.message = warning.code.replace('_', " "); + warning.action = None; + } + } + redacted +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use openbitfun_product_domains::legacy_migration::MigrationRunStatus; + + fn source(fingerprint: &str) -> LegacySourceDescriptor { + LegacySourceDescriptor { + source_fingerprint: fingerprint.to_string(), + supported: true, + ..LegacySourceDescriptor::default() + } + } + + #[test] + fn only_matching_source_preferences_suppress_onboarding() { + let mut state = MigrationOnboardingState { + source_fingerprint: "source-a".to_string(), + choice: MigrationPromptChoice::DoNotRemind, + ..MigrationOnboardingState::default() + }; + assert!(!should_offer_onboarding(&state, &source("source-a"))); + assert!(should_offer_onboarding(&state, &source("source-b"))); + + state.choice = MigrationPromptChoice::RemindLater; + assert!(should_offer_onboarding(&state, &source("source-a"))); + } + + #[test] + fn completed_source_does_not_offer_onboarding_again() { + let mut completed = source("source-a"); + completed.already_migrated = true; + assert!(!should_offer_onboarding( + &MigrationOnboardingState::default(), + &completed + )); + } + + #[test] + fn restart_argument_requires_one_uuid_value() { + let run_id = uuid::Uuid::new_v4().to_string(); + assert_eq!( + migration_restart_run_id([ + OsString::from("--unrelated"), + OsString::from("value"), + OsString::from("--legacy-migration-run-id"), + OsString::from(&run_id), + ]), + Some(run_id.clone()) + ); + assert_eq!( + migration_restart_run_id([ + OsString::from("--legacy-migration-run-id"), + OsString::from("invalid"), + ]), + None + ); + assert_eq!( + migration_restart_run_id([ + OsString::from("--legacy-migration-run-id"), + OsString::from(&run_id), + OsString::from("--legacy-migration-run-id"), + OsString::from(&run_id), + ]), + None + ); + } + + #[test] + fn settings_handoff_requires_every_current_migrator_capability() { + let request = new_handoff_request( + MigratorRequestMode::Execute, + MigratorRequestOrigin::Settings, + &source("source-a"), + MigrationSelection::all(), + ); + assert_eq!( + request.required_capabilities, + MigratorProtocolCapabilities::current().capabilities + ); + assert_eq!(request.mode, MigratorRequestMode::Execute); + assert_eq!(request.origin, MigratorRequestOrigin::Settings); + } + + #[test] + fn ui_report_removes_paths_and_free_text() { + let report = MigrationRunReport { + status: MigrationRunStatus::FailedRecoverable, + diagnostics: vec![ + openbitfun_product_domains::legacy_migration::MigrationDiagnostic { + code: "legacy_item_failed".to_string(), + relative_path: Some("private/session.md".to_string()), + message: "private body".to_string(), + action: Some("private action".to_string()), + ..Default::default() + }, + ], + ..MigrationRunReport::default() + }; + let redacted = redact_report_for_ui(&report); + assert!(redacted.diagnostics[0].relative_path.is_none()); + assert_eq!(redacted.diagnostics[0].message, "legacy item failed"); + assert!(redacted.diagnostics[0].action.is_none()); + } +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 38bb045244..ab82f4c294 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -31,6 +31,7 @@ pub mod git_api; pub mod html_preview_api; pub mod i18n_api; pub mod insights_api; +pub mod legacy_migration_api; pub mod mcp_api; pub mod miniapp_agent_api; pub mod miniapp_api; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index d113f3e0d9..c41822fffe 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -559,6 +559,19 @@ fn get_startup_native_trace( /// Tauri application entry point #[cfg_attr(mobile, tauri::mobile_entry_point)] pub async fn run() { + match api::legacy_migration_api::run_startup_probe() { + Ok(api::legacy_migration_api::StartupProbeDisposition::Continue { handled_run_id }) => { + api::legacy_migration_api::set_startup_handled_run_id(handled_run_id) + } + Ok(api::legacy_migration_api::StartupProbeDisposition::MigratorLaunched) => return, + Err(error) => { + show_fatal_startup_error(&format!( + "OpenBitFun could not safely inspect legacy BitFun migration state and cannot continue.\n\n{}\n\nRepair the installation or the migration state before retrying.", + api::legacy_migration_api::LegacyMigrationCommandError::from(error).message + )); + return; + } + } let startup_started = Instant::now(); let startup_trace_id = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1453,6 +1466,11 @@ pub async fn run() { api::agentic_api::set_session_memory_mode, webdriver_bridge_result, get_startup_native_trace, + api::legacy_migration_api::get_legacy_migration_status, + api::legacy_migration_api::scan_legacy_migration, + api::legacy_migration_api::prepare_legacy_migration, + api::legacy_migration_api::get_legacy_migration_report, + api::legacy_migration_api::set_legacy_migration_prompt_preference, api::agentic_api::list_sessions, api::agentic_api::list_pending_permission_requests, api::agentic_api::subscribe_permission_requests, diff --git a/src/crates/assembly/product-capabilities/src/lib.rs b/src/crates/assembly/product-capabilities/src/lib.rs index 3e8c8397ae..93179474cd 100644 --- a/src/crates/assembly/product-capabilities/src/lib.rs +++ b/src/crates/assembly/product-capabilities/src/lib.rs @@ -164,6 +164,7 @@ impl ProductCapabilityPack { pub enum DeliveryProfile { ProductFull, Desktop, + DataMigrator, Cli, Server, Remote, @@ -178,6 +179,7 @@ impl DeliveryProfile { match self { Self::ProductFull => "product-full", Self::Desktop => "desktop", + Self::DataMigrator => "data-migrator", Self::Cli => "cli", Self::Server => "server", Self::Remote => "remote", @@ -192,6 +194,7 @@ impl DeliveryProfile { &[ Self::ProductFull, Self::Desktop, + Self::DataMigrator, Self::Cli, Self::Server, Self::Remote, @@ -252,6 +255,10 @@ const PRODUCT_DELIVERY_PROFILE_ENTRIES: &[ProductDeliveryProfileEntry] = &[ DeliveryProfile::Desktop, ProductCoreDependencyMode::ProductFullCompatibility, ), + ProductDeliveryProfileEntry::new( + DeliveryProfile::DataMigrator, + ProductCoreDependencyMode::ExplicitCoreCapabilityClosure, + ), ProductDeliveryProfileEntry::new( DeliveryProfile::Cli, ProductCoreDependencyMode::ExplicitCoreCapabilityClosure, @@ -943,6 +950,7 @@ pub fn product_extension_capabilities_for_profile( DeliveryProfile::Server | DeliveryProfile::Remote | DeliveryProfile::Acp + | DeliveryProfile::DataMigrator | DeliveryProfile::Web | DeliveryProfile::MobileWeb | DeliveryProfile::Sdk => PluginRuntimeUnavailableReason::UnsupportedProfile, @@ -1129,6 +1137,7 @@ fn product_capability_registry_for_profile(profile: DeliveryProfile) -> ProductC } DeliveryProfile::Server | DeliveryProfile::Remote + | DeliveryProfile::DataMigrator | DeliveryProfile::Web | DeliveryProfile::MobileWeb => { ProductCapabilityRegistry::new(EMPTY_PRODUCT_CAPABILITY_PACKS) diff --git a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs index d3235e152b..e7efe94f4f 100644 --- a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs +++ b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs @@ -321,6 +321,10 @@ fn product_delivery_profile_matrix_documents_current_core_dependency_shape() { DeliveryProfile::Desktop, ProductCoreDependencyMode::ProductFullCompatibility, ), + ( + DeliveryProfile::DataMigrator, + ProductCoreDependencyMode::ExplicitCoreCapabilityClosure, + ), ( DeliveryProfile::Cli, ProductCoreDependencyMode::ExplicitCoreCapabilityClosure, @@ -366,6 +370,23 @@ fn product_assembly_plan_follows_core_dependency_matrix() { match entry.core_dependency_mode() { ProductCoreDependencyMode::ProductFullCompatibility | ProductCoreDependencyMode::ExplicitCoreCapabilityClosure => { + if entry.profile() == DeliveryProfile::DataMigrator { + assert!( + plan.capability_set().ids().is_empty(), + "data-migrator must not assemble Agent Runtime capabilities" + ); + assert!( + plan.capability_assembly() + .tool_provider_group_plan() + .is_empty(), + "data-migrator must not assemble runtime tool groups" + ); + assert!( + plan.feature_groups().is_empty(), + "data-migrator must not expose runtime feature groups" + ); + continue; + } assert!( !plan.capability_set().ids().is_empty(), "{} must retain runtime capabilities", @@ -412,6 +433,25 @@ fn product_assembly_plan_follows_core_dependency_matrix() { } } +#[test] +fn data_migrator_profile_is_a_minimal_non_agent_product_plan() { + let plan = product_assembly_plan_for_profile(DeliveryProfile::DataMigrator); + + assert_eq!(plan.profile().id(), "data-migrator"); + assert!(plan.capability_set().ids().is_empty()); + assert!(plan.capability_assembly().agent_ids().is_empty()); + assert!(plan.capability_assembly().service_requirements().is_empty()); + assert!(plan.feature_groups().is_empty()); + assert!(plan + .capability_assembly() + .tool_provider_group_plan() + .is_empty()); + assert_eq!( + plan.extension_capabilities().plugin_runtime(), + PluginRuntimeAvailability::disabled(PluginRuntimeUnavailableReason::UnsupportedProfile) + ); +} + #[test] fn product_assembly_plan_keeps_plugin_runtime_disabled_until_explicit_client_binding() { for profile in DeliveryProfile::all_current_product_profiles() { diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 1a16b0b74a..c53ddd9c50 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "d7a7419ddd673eb733ae8bd33dbb3dcd3b4d1ce0acfc953067a3403fe26d6699", + "digest": "cf1a4fae19862b0613cb1144cead8fcaaa7559b47d5a5f0f3a2c361be991083b", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -136,13 +136,13 @@ ], "counts": { "features": 22, - "settings": 21, - "userFacing": 43, - "documentedItems": 321, + "settings": 22, + "userFacing": 44, + "documentedItems": 326, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 212, + "interactive": 217, "unsupported": 0 } }, @@ -18765,6 +18765,285 @@ "pageId": "data.archived" } }, + { + "id": "setting.data.migration:query", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scan", + "scope", + "launch", + "report", + "reminder" + ], + "kind": "query", + "risk": "read", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:scan", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scan" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:scope", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scope" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "visualSelection", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:launch", + "capabilityId": "setting.data.migration", + "itemIds": [ + "launch" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:report", + "capabilityId": "setting.data.migration", + "itemIds": [ + "report" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:reminder", + "capabilityId": "setting.data.migration", + "itemIds": [ + "reminder" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, { "id": "setting.data.diagnostics:query", "capabilityId": "setting.data.diagnostics", @@ -29955,6 +30234,156 @@ ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.archived/" }, + { + "id": "setting.data.migration", + "kind": "setting", + "categoryId": "data", + "titleZh": "旧版数据迁移", + "titleEn": "Legacy data migration", + "summaryZh": "从本机旧版 BitFun 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", + "summaryEn": "Scan and import supported data from a local legacy BitFun installation, inspect redacted reports, and leave the legacy source unchanged.", + "keywordsZh": [ + "旧版数据迁移", + "BitFun 数据", + "迁移报告", + "导入旧数据", + "Data Migrator" + ], + "keywordsEn": [ + "legacy data migration", + "BitFun data", + "migration report", + "import old data", + "Data Migrator" + ], + "highlightsZh": [ + "只读扫描本机旧版 BitFun 数据", + "按五个高层数据组选择迁移范围", + "通过独立 Data Migrator 导入并查看去敏报告" + ], + "highlightsEn": [ + "Read-only scan of local legacy BitFun data", + "Choose migration scope across five high-level data groups", + "Import through the standalone Data Migrator and inspect redacted reports" + ], + "items": [ + { + "id": "scan", + "titleZh": "只读扫描本机旧版 BitFun 来源及所选数据组", + "titleEn": "Read-only scan the local legacy BitFun source and selected data groups", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." + } + }, + { + "id": "scope", + "titleZh": "选择设置、扩展、会话、记忆和远程连接迁移范围", + "titleEn": "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "control": { + "kind": "open", + "reasonCode": "visualSelection", + "reasonZh": "迁移范围是影响本机持久数据的五组可见选择;Agent 会打开精确入口,由用户确认所需范围。", + "reasonEn": "Migration scope is a visible five-group selection affecting local persisted data; the Agent opens the exact entry so the user can confirm the intended scope." + } + }, + { + "id": "launch", + "titleZh": "确认关闭影响后启动独立 Data Migrator", + "titleEn": "Launch the standalone Data Migrator after confirming shutdown impact", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." + } + }, + { + "id": "report", + "titleZh": "查看最近运行结果和各领域去敏状态", + "titleEn": "Inspect the latest run result and redacted per-domain status", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." + } + }, + { + "id": "reminder", + "titleZh": "恢复已关闭的首次启动迁移提醒", + "titleEn": "Restore the first-start migration reminder after it was disabled", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." + } + } + ], + "stepsZh": [ + "打开设置", + "进入“数据 > 旧版数据迁移”", + "扫描来源、选择范围并在确认关闭影响后启动迁移器" + ], + "stepsEn": [ + "Open Settings", + "Go to Data > Legacy data migration", + "Scan the source, choose scope, and launch the migrator after confirming shutdown impact" + ], + "agentExamplesZh": [ + "打开旧版数据迁移", + "带我查看 BitFun 迁移报告" + ], + "agentExamplesEn": [ + "Open legacy data migration", + "Show me the BitFun migration report" + ], + "destination": { + "kind": "settings", + "pageId": "data.migration" + }, + "operations": [], + "options": [], + "searchTerms": [ + "setting.data.migration", + "旧版数据迁移", + "Legacy data migration", + "数据与诊断", + "Data & diagnostics", + "BitFun 数据", + "迁移报告", + "导入旧数据", + "Data Migrator", + "legacy data migration", + "BitFun data", + "migration report", + "import old data", + "只读扫描本机旧版 BitFun 数据", + "按五个高层数据组选择迁移范围", + "通过独立 Data Migrator 导入并查看去敏报告", + "Read-only scan of local legacy BitFun data", + "Choose migration scope across five high-level data groups", + "Import through the standalone Data Migrator and inspect redacted reports", + "只读扫描本机旧版 BitFun 来源及所选数据组", + "Read-only scan the local legacy BitFun source and selected data groups", + "选择设置、扩展、会话、记忆和远程连接迁移范围", + "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "确认关闭影响后启动独立 Data Migrator", + "Launch the standalone Data Migrator after confirming shutdown impact", + "查看最近运行结果和各领域去敏状态", + "Inspect the latest run result and redacted per-domain status", + "恢复已关闭的首次启动迁移提醒", + "Restore the first-start migration reminder after it was disabled", + "打开旧版数据迁移", + "带我查看 BitFun 迁移报告", + "Open legacy data migration", + "Show me the BitFun migration report" + ], + "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.migration/" + }, { "id": "setting.data.diagnostics", "kind": "setting", diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index fe0d6941e8..535a2bd35b 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:4cdcd7ff4dcd85a9", + "digest": "fnv1a64:f5390eecb4710d17", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -2585,6 +2585,30 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, + { + "id": "get_legacy_migration_report", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, + { + "id": "get_legacy_migration_status", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "get_mcp_prompt", "surface": "tauri_command", @@ -5139,6 +5163,18 @@ "reason": "the CLI peer host has no handler for this command" } }, + { + "id": "prepare_legacy_migration", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "preview_commit_message", "surface": "tauri_command", @@ -6457,6 +6493,18 @@ "kind": "handled" } }, + { + "id": "scan_legacy_migration", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "scan_workspace_info", "surface": "tauri_command", @@ -6793,6 +6841,18 @@ "reason": "the CLI peer host has no handler for this command" } }, + { + "id": "set_legacy_migration_prompt_preference", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "set_macos_edit_menu_mode", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/legacy_migration/mod.rs b/src/crates/contracts/product-domains/src/legacy_migration/mod.rs index fe37c9eb99..054884de6b 100644 --- a/src/crates/contracts/product-domains/src/legacy_migration/mod.rs +++ b/src/crates/contracts/product-domains/src/legacy_migration/mod.rs @@ -21,9 +21,12 @@ pub enum MigrationGroupId { RemoteConnectionsAndDevices, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] #[serde(rename_all = "snake_case")] pub enum MigrationDomainId { + #[default] Settings, Credentials, Skills, @@ -346,9 +349,13 @@ pub struct MigrationOnboardingState { pub format_version: u32, pub source_fingerprint: String, pub detected_at_ms: Option, + /// Most recent explicit read-only scan started from a product entry point. + pub last_scanned_at_ms: Option, pub choice: MigrationPromptChoice, pub last_prompted_version: Option, pub run_id: Option, + /// Most recent run whose report can be shown after a later handoff starts. + pub last_report_run_id: Option, pub handled_run_id: Option, } @@ -371,12 +378,23 @@ pub enum MigratorRequestOrigin { Installer, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigratorProtocolCapability { + ReadOnlyScan, + OfflineExecute, + JournalRecovery, + SafeCancellation, + TrustedRestart, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct MigratorProtocolCapabilities { pub protocol_version: u32, pub minimum_compatible_version: u32, pub supported_modes: BTreeSet, + pub capabilities: BTreeSet, } impl MigratorProtocolCapabilities { @@ -388,6 +406,13 @@ impl MigratorProtocolCapabilities { MigratorRequestMode::Onboarding, MigratorRequestMode::Execute, ]), + capabilities: BTreeSet::from([ + MigratorProtocolCapability::ReadOnlyScan, + MigratorProtocolCapability::OfflineExecute, + MigratorProtocolCapability::JournalRecovery, + MigratorProtocolCapability::SafeCancellation, + MigratorProtocolCapability::TrustedRestart, + ]), } } @@ -396,6 +421,11 @@ impl MigratorProtocolCapabilities { && version <= self.protocol_version && self.supported_modes.contains(&mode) } + + pub fn accepts_request(&self, request: &MigratorHandoffRequest) -> bool { + self.accepts(request.protocol_version, request.mode) + && request.required_capabilities.is_subset(&self.capabilities) + } } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -414,6 +444,7 @@ pub struct MigratorHandoffRequest { pub release_channel: String, pub created_at_ms: i64, pub expires_at_ms: i64, + pub required_capabilities: BTreeSet, } impl MigratorHandoffRequest { @@ -448,9 +479,3 @@ pub struct MigrationJournalEvent { pub domain_state: Option, pub code: String, } - -impl Default for MigrationDomainId { - fn default() -> Self { - Self::Settings - } -} 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 6fcc483ed5..f9a5639a16 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -299,6 +299,8 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("get_global_skill_settings", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("get_health_status", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("get_latest_insights", LocalOnly, ControllerLocal, REFUSED), + op("get_legacy_migration_report", LocalOnly, ControllerLocal, REFUSED), + op("get_legacy_migration_status", LocalOnly, ControllerLocal, REFUSED), op("get_mcp_prompt", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("get_mcp_remote_oauth_session", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("get_mcp_server_status", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), @@ -514,6 +516,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("plan_external_hook_import_command", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("plan_external_mcp_import_command", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("predownload_acp_client_adapter", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), + op("prepare_legacy_migration", LocalOnly, ControllerLocal, REFUSED), op("preview_commit_message", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("probe_acp_client_requirements", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("product_control_invoke", Agnostic, Proxied, HANDLED), @@ -625,6 +628,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("save_session_metadata", Unaudited, Proxied, HANDLED), op("save_session_turn", Unaudited, Proxied, HANDLED), op("save_web_search_credential", Agnostic, Proxied, HANDLED), + op("scan_legacy_migration", LocalOnly, ControllerLocal, REFUSED), op("scan_workspace_info", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("search_build_index", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("search_file_contents", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), @@ -654,6 +658,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("set_external_tool_target_decision_command", Unsupported, Proxied, HANDLED), op("set_external_tool_targets_enabled_command", Unsupported, Proxied, HANDLED), op("set_global_skill_disabled", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), + op("set_legacy_migration_prompt_preference", LocalOnly, ControllerLocal, REFUSED), op("set_macos_edit_menu_mode", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("set_main_window_transient_geometry", LocalOnly, ControllerLocal, REFUSED), op("set_miniapp_draft_storage", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), diff --git a/src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs b/src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs index b6cab3ba2c..d07b5cd583 100644 --- a/src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs +++ b/src/crates/contracts/product-domains/tests/legacy_migration_contracts.rs @@ -1,7 +1,8 @@ use openbitfun_product_domains::legacy_migration::{ MigrationDomainId, MigrationDomainResult, MigrationGroupId, MigrationOnboardingState, MigrationPromptChoice, MigrationSelection, MigratorHandoffRequest, - MigratorProtocolCapabilities, MigratorRequestMode, CURRENT_MIGRATOR_PROTOCOL_VERSION, + MigratorProtocolCapabilities, MigratorProtocolCapability, MigratorRequestMode, + CURRENT_MIGRATOR_PROTOCOL_VERSION, }; use std::collections::BTreeSet; @@ -29,6 +30,8 @@ fn persisted_onboarding_shape_accepts_old_payloads() { assert_eq!(state.choice, MigrationPromptChoice::RemindLater); assert_eq!(state.format_version, 0); assert!(state.run_id.is_none()); + assert!(state.last_scanned_at_ms.is_none()); + assert!(state.last_report_run_id.is_none()); } #[test] @@ -57,10 +60,35 @@ fn handoff_rejects_expired_or_future_protocol_requests() { .expect("request should accept additive defaults"); let capabilities = MigratorProtocolCapabilities::current(); - assert!(capabilities.accepts(request.protocol_version, request.mode)); + assert!(capabilities.accepts_request(&request)); assert!(!capabilities.accepts( CURRENT_MIGRATOR_PROTOCOL_VERSION + 1, MigratorRequestMode::Onboarding )); assert!(request.is_expired_at(21)); } + +#[test] +fn handoff_capability_negotiation_is_additive_and_fail_closed() { + let old_request: MigratorHandoffRequest = serde_json::from_value(serde_json::json!({ + "protocolVersion": CURRENT_MIGRATOR_PROTOCOL_VERSION, + "mode": "execute" + })) + .expect("older requests must default the additive capability set"); + assert!(old_request.required_capabilities.is_empty()); + assert!(MigratorProtocolCapabilities::current().accepts_request(&old_request)); + + let mut required = BTreeSet::new(); + required.insert(MigratorProtocolCapability::JournalRecovery); + let request = MigratorHandoffRequest { + required_capabilities: required, + protocol_version: CURRENT_MIGRATOR_PROTOCOL_VERSION, + mode: MigratorRequestMode::Execute, + ..MigratorHandoffRequest::default() + }; + assert!(MigratorProtocolCapabilities::current().accepts_request(&request)); + + let mut limited = MigratorProtocolCapabilities::current(); + limited.capabilities.clear(); + assert!(!limited.accepts_request(&request)); +} diff --git a/src/crates/services/legacy-migration/Cargo.toml b/src/crates/services/legacy-migration/Cargo.toml index 59513b03cc..de6518a87d 100644 --- a/src/crates/services/legacy-migration/Cargo.toml +++ b/src/crates/services/legacy-migration/Cargo.toml @@ -24,7 +24,7 @@ dirs = { workspace = true } fs2 = { workspace = true } hex = { workspace = true } openbitfun-product-domains = { path = "../../contracts/product-domains", features = ["legacy-migration"] } -openbitfun-services-core = { path = "../services-core", features = ["product-identity"] } +openbitfun-services-core = { path = "../services-core", features = ["process-runtime", "product-identity"] } rusqlite = { workspace = true, features = ["backup", "bundled"] } semver = { workspace = true } serde = { workspace = true } @@ -34,7 +34,19 @@ thiserror = { workspace = true } uuid = { workspace = true } [target.'cfg(windows)'.dependencies] -windows = { workspace = true, features = ["Win32_Storage_FileSystem"] } +windows = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Cryptography", + "Win32_Security_WinTrust", + "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Threading", +] } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/src/crates/services/legacy-migration/src/engine.rs b/src/crates/services/legacy-migration/src/engine.rs index 76a673e6e6..d762366cd8 100644 --- a/src/crates/services/legacy-migration/src/engine.rs +++ b/src/crates/services/legacy-migration/src/engine.rs @@ -176,6 +176,22 @@ impl MigrationEngine { source: &LegacySourceDescriptor, selection: MigrationSelection, cancellation: &CancellationToken, + ) -> LegacyMigrationResult { + self.plan_with_run_id( + source, + selection, + uuid::Uuid::new_v4().to_string(), + cancellation, + ) + } + + /// Build an immutable plan for a run id authenticated by the handoff file. + pub fn plan_with_run_id( + &self, + source: &LegacySourceDescriptor, + selection: MigrationSelection, + run_id: impl Into, + cancellation: &CancellationToken, ) -> LegacyMigrationResult { if !source.readable || !source.supported { return Err(LegacyMigrationError::UnsupportedSource( @@ -215,9 +231,15 @@ impl MigrationEngine { conflicts.extend(scan.conflicts); } + let run_id = run_id.into(); + if uuid::Uuid::parse_str(&run_id).is_err() { + return Err(LegacyMigrationError::InvalidRequest( + "migration run id must be a UUID".to_string(), + )); + } let mut plan = MigrationPlan { format_version: CURRENT_MIGRATION_FORMAT_VERSION, - run_id: uuid::Uuid::new_v4().to_string(), + run_id, source_fingerprint: source.source_fingerprint.clone(), selection, steps, @@ -374,6 +396,15 @@ impl MigrationEngine { let _ = record_cancelled(&layout, &mut report, &mut journal_sequence); error })?; + emit_progress( + &mut progress, + plan, + step, + MigrationPhase::ValidateStage, + index, + true, + "validating_staged_domain", + ); transition( &layout, &mut report, @@ -413,6 +444,15 @@ impl MigrationEngine { let _ = record_cancelled(&layout, &mut report, &mut journal_sequence); error })?; + emit_progress( + &mut progress, + plan, + step, + MigrationPhase::Commit, + index, + false, + "committing_domain", + ); transition( &layout, &mut report, @@ -451,6 +491,15 @@ impl MigrationEngine { } if report.domain_results[result_index].state == MigrationDomainState::Committed { + emit_progress( + &mut progress, + plan, + step, + MigrationPhase::ValidateCommit, + index, + false, + "validating_committed_domain", + ); transition( &layout, &mut report, @@ -520,6 +569,15 @@ impl MigrationEngine { crash_injector, CrashPoint::AfterCommitValidated(step.domain), )?; + emit_progress( + &mut progress, + plan, + step, + MigrationPhase::ValidateCommit, + index + 1, + true, + "domain_verified", + ); } } diff --git a/src/crates/services/legacy-migration/src/error.rs b/src/crates/services/legacy-migration/src/error.rs index 99a6063048..ad189c8bde 100644 --- a/src/crates/services/legacy-migration/src/error.rs +++ b/src/crates/services/legacy-migration/src/error.rs @@ -25,6 +25,12 @@ pub enum LegacyMigrationError { LockUnavailable, #[error("legacy migration was cancelled at a safe boundary")] Cancelled, + #[error("legacy migration process inspection failed: {0}")] + ProcessInspection(String), + #[error("legacy migration executable is not trusted: {0}")] + UntrustedExecutable(PathBuf), + #[error("legacy migration trusted installation is unavailable: {0}")] + TrustedInstallationUnavailable(String), #[error("legacy migration crash injection at {0:?}")] InjectedCrash(crate::CrashPoint), #[error("legacy migration domain {domain:?} failed: {message}")] diff --git a/src/crates/services/legacy-migration/src/handoff.rs b/src/crates/services/legacy-migration/src/handoff.rs new file mode 100644 index 0000000000..4341696d08 --- /dev/null +++ b/src/crates/services/legacy-migration/src/handoff.rs @@ -0,0 +1,1113 @@ +use crate::{ + atomic_write_json, LegacyMigrationError, LegacyMigrationResult, MigrationLayout, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{ + MigrationPlan, MigratorHandoffRequest, MigratorProtocolCapabilities, MigratorRequestMode, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::ffi::OsStr; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; + +const MAX_HANDOFF_REQUEST_BYTES: u64 = 64 * 1024; +const MAX_HANDOFF_LIFETIME_MS: i64 = 15 * 60 * 1000; +const MAX_CLOCK_SKEW_MS: i64 = 60 * 1000; +const NONCE_RECEIPT_FORMAT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HandoffDisposition { + Fresh, + Recovery, +} + +#[derive(Debug, Clone)] +pub struct ValidatedHandoff { + request: MigratorHandoffRequest, + layout: MigrationLayout, + disposition: HandoffDisposition, +} + +impl ValidatedHandoff { + pub fn request(&self) -> &MigratorHandoffRequest { + &self.request + } + + pub fn layout(&self) -> &MigrationLayout { + &self.layout + } + + pub const fn disposition(&self) -> HandoffDisposition { + self.disposition + } +} + +#[derive(Debug, Clone)] +pub struct HandoffStore { + roots: MigrationRoots, + expected_product_id: String, + expected_release_channel: String, +} + +impl HandoffStore { + pub fn new( + roots: MigrationRoots, + expected_product_id: impl Into, + expected_release_channel: impl Into, + ) -> Self { + Self { + roots, + expected_product_id: expected_product_id.into(), + expected_release_channel: expected_release_channel.into(), + } + } + + pub fn write_request( + &self, + request: &MigratorHandoffRequest, + now_ms: i64, + ) -> LegacyMigrationResult { + self.validate_request(request, &request.run_id, now_ms)?; + let layout = MigrationLayout::new(&self.roots, &request.run_id); + initialize_private_layout(&layout)?; + ensure_path_chain_is_plain(layout.root(), &layout.request_path())?; + write_new_private_json(&layout.request_path(), request)?; + verify_current_user_owned(&layout.request_path())?; + Ok(layout.request_path()) + } + + pub fn load_request( + &self, + run_id: &str, + now_ms: i64, + ) -> LegacyMigrationResult { + validate_uuid("run id", run_id)?; + let layout = MigrationLayout::new(&self.roots, run_id); + ensure_path_chain_is_plain(layout.root(), &layout.request_path())?; + verify_current_user_owned(&layout.request_path())?; + let request = read_bounded_json::( + &layout.request_path(), + MAX_HANDOFF_REQUEST_BYTES, + )?; + self.validate_request(&request, run_id, now_ms)?; + + let disposition = match read_optional_bounded_json::( + &layout.consumed_nonce_path(), + MAX_HANDOFF_REQUEST_BYTES, + )? { + None => HandoffDisposition::Fresh, + Some(receipt) => { + validate_nonce_receipt(&receipt, &request)?; + let persisted_plan = layout + .read_json::(&layout.plan_path())? + .ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "handoff nonce was consumed before an immutable plan was stored" + .to_string(), + ) + })?; + validate_plan_binding(&persisted_plan, &request)?; + HandoffDisposition::Recovery + } + }; + + Ok(ValidatedHandoff { + request, + layout, + disposition, + }) + } + + /// Persist the immutable plan before consuming the one-time nonce. + /// + /// This ordering leaves a recoverable plan if the process stops immediately + /// after the nonce receipt is made durable. Reusing a receipt is accepted + /// only for that exact plan and request. + pub fn authorize_plan( + &self, + handoff: &ValidatedHandoff, + plan: &MigrationPlan, + now_ms: i64, + ) -> LegacyMigrationResult { + self.validate_request(&handoff.request, &handoff.request.run_id, now_ms)?; + validate_plan_binding(plan, &handoff.request)?; + if let Some(existing) = handoff + .layout + .read_json::(&handoff.layout.plan_path())? + { + if existing != *plan { + return Err(LegacyMigrationError::InvalidPlan( + "handoff run already has a different immutable plan".to_string(), + )); + } + } else { + atomic_write_json(&handoff.layout.plan_path(), plan)?; + } + + let receipt = ConsumedNonceReceipt::new(&handoff.request, now_ms); + match write_new_private_json(&handoff.layout.consumed_nonce_path(), &receipt) { + Ok(()) => Ok(HandoffDisposition::Fresh), + Err(LegacyMigrationError::Io { source, .. }) + if source.kind() == std::io::ErrorKind::AlreadyExists => + { + let existing = read_bounded_json::( + &handoff.layout.consumed_nonce_path(), + MAX_HANDOFF_REQUEST_BYTES, + )?; + validate_nonce_receipt(&existing, &handoff.request)?; + Ok(HandoffDisposition::Recovery) + } + Err(error) => Err(error), + } + } + + pub fn load_authorized_plan( + &self, + handoff: &ValidatedHandoff, + ) -> LegacyMigrationResult> { + let Some(receipt) = read_optional_bounded_json::( + &handoff.layout.consumed_nonce_path(), + MAX_HANDOFF_REQUEST_BYTES, + )? + else { + return Ok(None); + }; + validate_nonce_receipt(&receipt, &handoff.request)?; + let plan = handoff + .layout + .read_json::(&handoff.layout.plan_path())?; + if let Some(plan) = &plan { + validate_plan_binding(plan, &handoff.request)?; + } + Ok(plan) + } + + fn validate_request( + &self, + request: &MigratorHandoffRequest, + expected_run_id: &str, + now_ms: i64, + ) -> LegacyMigrationResult<()> { + validate_uuid("run id", &request.run_id)?; + validate_uuid("nonce", &request.nonce)?; + if request.run_id != expected_run_id { + return Err(LegacyMigrationError::InvalidRequest( + "handoff request does not match the derived run path".to_string(), + )); + } + if !MigratorProtocolCapabilities::current().accepts_request(request) { + return Err(LegacyMigrationError::InvalidRequest( + "handoff protocol or required capabilities are unsupported".to_string(), + )); + } + if request.product_id != self.expected_product_id { + return Err(LegacyMigrationError::InvalidRequest( + "handoff request belongs to a different product identity".to_string(), + )); + } + if request.release_channel != self.expected_release_channel { + return Err(LegacyMigrationError::InvalidRequest( + "handoff request belongs to a different release channel".to_string(), + )); + } + if request.caller_process_id == 0 { + return Err(LegacyMigrationError::InvalidRequest( + "handoff request has no caller process identity".to_string(), + )); + } + if request.created_at_ms > now_ms.saturating_add(MAX_CLOCK_SKEW_MS) { + return Err(LegacyMigrationError::InvalidRequest( + "handoff request creation time is in the future".to_string(), + )); + } + if request.expires_at_ms <= request.created_at_ms + || request.expires_at_ms.saturating_sub(request.created_at_ms) > MAX_HANDOFF_LIFETIME_MS + { + return Err(LegacyMigrationError::InvalidRequest( + "handoff request lifetime is invalid".to_string(), + )); + } + if request.is_expired_at(now_ms) { + return Err(LegacyMigrationError::InvalidRequest( + "handoff request has expired".to_string(), + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct ConsumedNonceReceipt { + format_version: u32, + run_id: String, + nonce_sha256: String, + consumed_at_ms: i64, +} + +impl Default for ConsumedNonceReceipt { + fn default() -> Self { + Self { + format_version: NONCE_RECEIPT_FORMAT_VERSION, + run_id: String::new(), + nonce_sha256: String::new(), + consumed_at_ms: 0, + } + } +} + +impl ConsumedNonceReceipt { + fn new(request: &MigratorHandoffRequest, consumed_at_ms: i64) -> Self { + Self { + format_version: NONCE_RECEIPT_FORMAT_VERSION, + run_id: request.run_id.clone(), + nonce_sha256: nonce_digest(&request.nonce), + consumed_at_ms, + } + } +} + +fn validate_nonce_receipt( + receipt: &ConsumedNonceReceipt, + request: &MigratorHandoffRequest, +) -> LegacyMigrationResult<()> { + if receipt.format_version != NONCE_RECEIPT_FORMAT_VERSION + || receipt.run_id != request.run_id + || receipt.nonce_sha256 != nonce_digest(&request.nonce) + { + return Err(LegacyMigrationError::InvalidRequest( + "handoff nonce has already been consumed by another request".to_string(), + )); + } + Ok(()) +} + +fn validate_plan_binding( + plan: &MigrationPlan, + request: &MigratorHandoffRequest, +) -> LegacyMigrationResult<()> { + if plan.run_id != request.run_id { + return Err(LegacyMigrationError::InvalidPlan( + "migration plan does not belong to the handoff run".to_string(), + )); + } + if let Some(source_fingerprint) = request.source_fingerprint.as_deref() { + if plan.source_fingerprint != source_fingerprint { + return Err(LegacyMigrationError::InvalidPlan( + "migration plan does not match the requested legacy source".to_string(), + )); + } + } + if request.mode == MigratorRequestMode::Execute && plan.selection != request.selection { + return Err(LegacyMigrationError::InvalidPlan( + "migration plan selection differs from the confirmed handoff selection".to_string(), + )); + } + Ok(()) +} + +fn nonce_digest(nonce: &str) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(nonce.as_bytes()))) +} + +fn validate_uuid(label: &str, value: &str) -> LegacyMigrationResult<()> { + uuid::Uuid::parse_str(value).map_err(|_| { + LegacyMigrationError::InvalidRequest(format!("handoff {label} must be a UUID")) + })?; + Ok(()) +} + +fn initialize_private_layout(layout: &MigrationLayout) -> LegacyMigrationResult<()> { + layout.initialize()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [ + layout.root(), + &layout.run_root(), + &layout.stage_root(), + &layout.backup_root(), + ] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|error| LegacyMigrationError::io(path, error))?; + } + } + Ok(()) +} + +fn write_new_private_json(path: &Path, value: &T) -> LegacyMigrationResult<()> { + let mut bytes = serde_json::to_vec_pretty(value) + .map_err(|error| LegacyMigrationError::json(path, error))?; + bytes.push(b'\n'); + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(path) + .map_err(|error| LegacyMigrationError::io(path, error))?; + file.write_all(&bytes) + .map_err(|error| LegacyMigrationError::io(path, error))?; + file.sync_all() + .map_err(|error| LegacyMigrationError::io(path, error))?; + if let Some(parent) = path.parent() { + if let Ok(directory) = File::open(parent) { + let _ = directory.sync_all(); + } + } + Ok(()) +} + +fn read_bounded_json( + path: &Path, + max_bytes: u64, +) -> LegacyMigrationResult { + let metadata = + fs::symlink_metadata(path).map_err(|error| LegacyMigrationError::io(path, error))?; + if is_link_or_reparse(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path.to_path_buf())); + } + if metadata.len() > max_bytes { + return Err(LegacyMigrationError::ResourceLimit(format!( + "handoff file exceeds {max_bytes} bytes" + ))); + } + let bytes = fs::read(path).map_err(|error| LegacyMigrationError::io(path, error))?; + serde_json::from_slice(&bytes).map_err(|error| LegacyMigrationError::json(path, error)) +} + +fn read_optional_bounded_json( + path: &Path, + max_bytes: u64, +) -> LegacyMigrationResult> { + match read_bounded_json(path, max_bytes) { + Ok(value) => Ok(Some(value)), + Err(LegacyMigrationError::Io { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => + { + Ok(None) + } + Err(error) => Err(error), + } +} + +fn ensure_path_chain_is_plain(root: &Path, target: &Path) -> LegacyMigrationResult<()> { + let relative = target + .strip_prefix(root) + .map_err(|_| LegacyMigrationError::PathEscape(target.to_path_buf()))?; + let mut current = root.to_path_buf(); + if let Ok(metadata) = fs::symlink_metadata(¤t) { + if is_link_or_reparse(&metadata) { + return Err(LegacyMigrationError::LinkedPath(current)); + } + } + for component in relative.components() { + let Component::Normal(component) = component else { + return Err(LegacyMigrationError::PathEscape(target.to_path_buf())); + }; + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if is_link_or_reparse(&metadata) => { + return Err(LegacyMigrationError::LinkedPath(current)); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(LegacyMigrationError::io(¤t, error)), + } + } + Ok(()) +} + +#[cfg(windows)] +fn verify_current_user_owned(path: &Path) -> LegacyMigrationResult<()> { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL}; + use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}; + use windows::Win32::Security::{ + EqualSid, GetTokenInformation, TokenUser, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, + PSID, TOKEN_QUERY, TOKEN_USER, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + struct OwnedHandle(HANDLE); + impl Drop for OwnedHandle { + fn drop(&mut self) { + // SAFETY: this wrapper owns the process token handle exactly once. + unsafe { + let _ = CloseHandle(self.0); + } + } + } + + struct LocalSecurityDescriptor(PSECURITY_DESCRIPTOR); + impl Drop for LocalSecurityDescriptor { + fn drop(&mut self) { + // SAFETY: GetNamedSecurityInfoW allocated this descriptor with + // LocalAlloc-compatible ownership and this wrapper frees it once. + unsafe { + let _ = LocalFree(Some(HLOCAL(self.0 .0))); + } + } + } + + let wide_path = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let mut owner = PSID::default(); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + // SAFETY: every output pointer refers to initialized storage that lives + // through the call; the UTF-16 path is NUL terminated. + let status = unsafe { + GetNamedSecurityInfoW( + PCWSTR(wide_path.as_ptr()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + Some(&mut owner), + None, + None, + None, + &mut descriptor, + ) + }; + if status.0 != 0 || descriptor.0.is_null() || owner.0.is_null() { + return Err(LegacyMigrationError::InvalidRequest( + "handoff file owner could not be verified".to_string(), + )); + } + let _descriptor = LocalSecurityDescriptor(descriptor); + + let mut raw_token = HANDLE::default(); + // SAFETY: `raw_token` is writable and becomes owned by `OwnedHandle` only + // after OpenProcessToken succeeds. + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut raw_token) } + .map_err(|error| LegacyMigrationError::ProcessInspection(error.to_string()))?; + let token = OwnedHandle(raw_token); + let mut required = 0u32; + // SAFETY: the zero-length probe passes no output buffer and a valid size + // pointer, as required by GetTokenInformation. + let _ = unsafe { GetTokenInformation(token.0, TokenUser, None, 0, &mut required) }; + if required < std::mem::size_of::() as u32 { + return Err(LegacyMigrationError::ProcessInspection( + "current user token identity is unavailable".to_string(), + )); + } + let word_count = (required as usize).div_ceil(std::mem::size_of::()); + let mut buffer = vec![0usize; word_count]; + // SAFETY: `buffer` has exactly the size reported by the preceding probe and + // remains live through both this call and the TOKEN_USER view below. + unsafe { + GetTokenInformation( + token.0, + TokenUser, + Some(buffer.as_mut_ptr() as *mut c_void), + required, + &mut required, + ) + } + .map_err(|error| LegacyMigrationError::ProcessInspection(error.to_string()))?; + // SAFETY: GetTokenInformation successfully initialized a TOKEN_USER at the + // start of the aligned allocator buffer for the duration of this scope. + let token_user = unsafe { &*(buffer.as_ptr() as *const TOKEN_USER) }; + // SAFETY: both SIDs are owned by live security descriptor/token buffers. + if unsafe { EqualSid(owner, token_user.User.Sid) }.is_err() { + return Err(LegacyMigrationError::InvalidRequest( + "handoff file is owned by another OS user".to_string(), + )); + } + Ok(()) +} + +#[cfg(unix)] +fn verify_current_user_owned(path: &Path) -> LegacyMigrationResult<()> { + use std::os::unix::fs::MetadataExt; + let metadata = + fs::symlink_metadata(path).map_err(|error| LegacyMigrationError::io(path, error))?; + // SAFETY: geteuid takes no pointers and has no caller-side preconditions. + if metadata.uid() != unsafe { libc::geteuid() } { + return Err(LegacyMigrationError::InvalidRequest( + "handoff file is owned by another OS user".to_string(), + )); + } + Ok(()) +} + +#[cfg(windows)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x00000400; + metadata.file_type().is_symlink() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WriterProcess { + pub process_id: u32, + pub executable_name: String, + pub is_handoff_caller: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProcessEntry { + process_id: u32, + executable_name: String, +} + +const WRITER_EXECUTABLE_NAMES: &[&str] = &[ + "bitfun.exe", + "bitfun-desktop.exe", + "bitfun", + "bitfun-desktop", + "openbitfun.exe", + "openbitfun-desktop.exe", + "openbitfun-agent-runtime.exe", + "openbitfun", + "openbitfun-desktop", + "openbitfun-agent-runtime", +]; + +pub fn blocking_writer_processes( + caller_process_id: u32, +) -> LegacyMigrationResult> { + blocking_writer_processes_for_product(caller_process_id, &[]) +} + +/// Find legacy and current writers, including product-defined sibling names. +/// +/// The additional names come from the build-time product projection rather +/// than the handoff request, so a request cannot broaden process matching. +pub fn blocking_writer_processes_for_product( + caller_process_id: u32, + product_writer_binary_names: &[&str], +) -> LegacyMigrationResult> { + let entries = platform_process_entries()?; + Ok(classify_writer_processes( + &entries, + caller_process_id, + std::process::id(), + product_writer_binary_names, + )) +} + +fn classify_writer_processes( + entries: &[ProcessEntry], + caller_process_id: u32, + current_process_id: u32, + product_writer_binary_names: &[&str], +) -> Vec { + let mut blockers = entries + .iter() + .filter(|entry| entry.process_id != current_process_id) + .filter_map(|entry| { + let is_handoff_caller = entry.process_id == caller_process_id; + let known_writer = WRITER_EXECUTABLE_NAMES + .iter() + .chain(product_writer_binary_names.iter()) + .any(|name| process_name_matches_binary(&entry.executable_name, name)); + (is_handoff_caller || known_writer).then(|| WriterProcess { + process_id: entry.process_id, + executable_name: entry.executable_name.clone(), + is_handoff_caller, + }) + }) + .collect::>(); + blockers.sort_by_key(|process| process.process_id); + blockers.dedup_by_key(|process| process.process_id); + blockers +} + +fn process_name_matches_binary(process_name: &str, binary_name: &str) -> bool { + process_name.eq_ignore_ascii_case(binary_name) + || process_name.eq_ignore_ascii_case(&platform_binary_filename(binary_name)) +} + +#[cfg(windows)] +fn platform_process_entries() -> LegacyMigrationResult> { + use std::mem::size_of; + use windows::Win32::Foundation::{CloseHandle, HANDLE}; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + + struct Snapshot(HANDLE); + impl Drop for Snapshot { + fn drop(&mut self) { + // SAFETY: this wrapper owns the ToolHelp snapshot handle exactly + // once and never exposes it beyond this scope. + unsafe { + let _ = CloseHandle(self.0); + } + } + } + + let snapshot = Snapshot( + // SAFETY: the system call receives a documented snapshot flag and no + // caller-owned pointers. + unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) } + .map_err(|error| LegacyMigrationError::ProcessInspection(error.to_string()))?, + ); + let mut entry = PROCESSENTRY32W { + dwSize: size_of::() as u32, + ..Default::default() + }; + let mut entries = Vec::new(); + // SAFETY: the snapshot is live and `entry.dwSize` identifies the complete + // writable PROCESSENTRY32W buffer. + if unsafe { Process32FirstW(snapshot.0, &mut entry) }.is_err() { + return Ok(entries); + } + loop { + let length = entry + .szExeFile + .iter() + .position(|character| *character == 0) + .unwrap_or(entry.szExeFile.len()); + entries.push(ProcessEntry { + process_id: entry.th32ProcessID, + executable_name: String::from_utf16_lossy(&entry.szExeFile[..length]), + }); + // SAFETY: the same live snapshot and correctly sized entry buffer are + // reused serially until enumeration completes. + if unsafe { Process32NextW(snapshot.0, &mut entry) }.is_err() { + break; + } + } + Ok(entries) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_process_entries() -> LegacyMigrationResult> { + let mut entries = Vec::new(); + let directory = fs::read_dir("/proc") + .map_err(|error| LegacyMigrationError::ProcessInspection(error.to_string()))?; + for entry in directory.flatten() { + let Some(process_id) = entry.file_name().to_string_lossy().parse::().ok() else { + continue; + }; + let executable_name = fs::read_to_string(entry.path().join("comm")) + .unwrap_or_default() + .trim() + .to_string(); + entries.push(ProcessEntry { + process_id, + executable_name, + }); + } + Ok(entries) +} + +#[cfg(target_os = "macos")] +fn platform_process_entries() -> LegacyMigrationResult> { + Err(LegacyMigrationError::ProcessInspection( + "process inventory is not implemented for macOS".to_string(), + )) +} + +pub trait ExecutableTrustVerifier { + fn verify(&self, path: &Path) -> LegacyMigrationResult<()>; +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct PlatformExecutableTrustVerifier; + +impl ExecutableTrustVerifier for PlatformExecutableTrustVerifier { + fn verify(&self, path: &Path) -> LegacyMigrationResult<()> { + platform_verify_executable(path) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustedExecutable { + current_executable: PathBuf, + target_executable: PathBuf, +} + +impl TrustedExecutable { + pub fn current_executable(&self) -> &Path { + &self.current_executable + } + + pub fn target_executable(&self) -> &Path { + &self.target_executable + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct TrustedInstallationResolver; + +impl TrustedInstallationResolver { + pub fn resolve_sibling( + current_executable: &Path, + expected_current_binary_name: &str, + expected_target_binary_name: &str, + verifier: &dyn ExecutableTrustVerifier, + ) -> LegacyMigrationResult { + validate_binary_name(expected_current_binary_name)?; + validate_binary_name(expected_target_binary_name)?; + let current = fs::canonicalize(current_executable) + .map_err(|error| LegacyMigrationError::io(current_executable, error))?; + let current_name = current.file_name().and_then(OsStr::to_str).ok_or_else(|| { + LegacyMigrationError::TrustedInstallationUnavailable( + "current executable has no Unicode file name".to_string(), + ) + })?; + if !names_equal( + current_name, + &platform_binary_filename(expected_current_binary_name), + ) { + return Err(LegacyMigrationError::UntrustedExecutable(current)); + } + reject_linked_executable(¤t)?; + let install_root = current.parent().ok_or_else(|| { + LegacyMigrationError::TrustedInstallationUnavailable( + "current executable has no installation directory".to_string(), + ) + })?; + let target_path = install_root.join(platform_binary_filename(expected_target_binary_name)); + reject_linked_executable(&target_path)?; + let target = fs::canonicalize(&target_path) + .map_err(|error| LegacyMigrationError::io(&target_path, error))?; + if target.parent() != Some(install_root) { + return Err(LegacyMigrationError::UntrustedExecutable(target)); + } + verifier.verify(¤t)?; + verifier.verify(&target)?; + Ok(TrustedExecutable { + current_executable: current, + target_executable: target, + }) + } +} + +pub fn launch_trusted_executable( + executable: &TrustedExecutable, + arguments: &[&OsStr], +) -> LegacyMigrationResult { + let child = openbitfun_services_core::process_manager::create_detached_command( + executable.target_executable(), + ) + .args(arguments) + .spawn() + .map_err(|error| LegacyMigrationError::io(executable.target_executable(), error))?; + Ok(child.id()) +} + +fn validate_binary_name(name: &str) -> LegacyMigrationResult<()> { + if name.is_empty() + || Path::new(name).components().count() != 1 + || matches!(name, "." | "..") + || name.contains(['/', '\\', '\0']) + { + return Err(LegacyMigrationError::TrustedInstallationUnavailable( + "trusted executable name is invalid".to_string(), + )); + } + Ok(()) +} + +#[cfg(windows)] +fn platform_binary_filename(name: &str) -> String { + if name.to_ascii_lowercase().ends_with(".exe") { + name.to_string() + } else { + format!("{name}.exe") + } +} + +#[cfg(not(windows))] +fn platform_binary_filename(name: &str) -> String { + name.to_string() +} + +#[cfg(windows)] +fn names_equal(actual: &str, expected: &str) -> bool { + actual.eq_ignore_ascii_case(expected) +} + +#[cfg(not(windows))] +fn names_equal(actual: &str, expected: &str) -> bool { + actual == expected +} + +fn reject_linked_executable(path: &Path) -> LegacyMigrationResult<()> { + let metadata = + fs::symlink_metadata(path).map_err(|error| LegacyMigrationError::io(path, error))?; + if !metadata.is_file() || is_link_or_reparse(&metadata) { + return Err(LegacyMigrationError::UntrustedExecutable( + path.to_path_buf(), + )); + } + Ok(()) +} + +#[cfg(windows)] +fn platform_verify_executable(path: &Path) -> LegacyMigrationResult<()> { + use std::ffi::c_void; + use std::mem::size_of; + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Foundation::{HANDLE, HWND}; + use windows::Win32::Security::WinTrust::{ + WinVerifyTrust, WINTRUST_ACTION_GENERIC_VERIFY_V2, WINTRUST_DATA, WINTRUST_DATA_0, + WINTRUST_FILE_INFO, WTD_CACHE_ONLY_URL_RETRIEVAL, WTD_CHOICE_FILE, + WTD_REVOCATION_CHECK_NONE, WTD_REVOKE_NONE, WTD_STATEACTION_IGNORE, WTD_UI_NONE, + }; + + if cfg!(debug_assertions) + && std::env::var_os("OPENBITFUN_ALLOW_UNSIGNED_MIGRATOR_DEV").as_deref() + == Some(OsStr::new("1")) + { + return Ok(()); + } + + let wide_path = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let mut file_info = WINTRUST_FILE_INFO { + cbStruct: size_of::() as u32, + pcwszFilePath: PCWSTR(wide_path.as_ptr()), + hFile: HANDLE::default(), + pgKnownSubject: std::ptr::null_mut(), + }; + let mut trust_data = WINTRUST_DATA { + cbStruct: size_of::() as u32, + dwUIChoice: WTD_UI_NONE, + fdwRevocationChecks: WTD_REVOKE_NONE, + dwUnionChoice: WTD_CHOICE_FILE, + Anonymous: WINTRUST_DATA_0 { + pFile: &mut file_info, + }, + dwStateAction: WTD_STATEACTION_IGNORE, + dwProvFlags: WTD_CACHE_ONLY_URL_RETRIEVAL | WTD_REVOCATION_CHECK_NONE, + ..Default::default() + }; + let mut policy = WINTRUST_ACTION_GENERIC_VERIFY_V2; + // SAFETY: all WinTrust structures and the NUL-terminated path remain live + // for the synchronous verification call; no state handle is retained. + let status = unsafe { + WinVerifyTrust( + HWND::default(), + &mut policy, + &mut trust_data as *mut _ as *mut c_void, + ) + }; + if status == 0 { + Ok(()) + } else { + Err(LegacyMigrationError::UntrustedExecutable( + path.to_path_buf(), + )) + } +} + +#[cfg(not(windows))] +fn platform_verify_executable(_path: &Path) -> LegacyMigrationResult<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use openbitfun_product_domains::legacy_migration::{ + MigrationSelection, MigratorProtocolCapability, MigratorRequestOrigin, + CURRENT_MIGRATION_FORMAT_VERSION, CURRENT_MIGRATOR_PROTOCOL_VERSION, + }; + use std::collections::BTreeSet; + + fn roots(root: &Path) -> MigrationRoots { + MigrationRoots { + legacy_user_root: root.join("legacy/user"), + legacy_home_root: root.join("legacy/home"), + legacy_skills_root: root.join("legacy/skills"), + legacy_ssh_root: root.join("legacy/ssh"), + target_user_root: root.join("target/user"), + target_home_root: root.join("target/home"), + target_skills_root: root.join("target/skills"), + target_ssh_root: root.join("target/ssh"), + } + } + + fn request(now_ms: i64) -> MigratorHandoffRequest { + MigratorHandoffRequest { + protocol_version: CURRENT_MIGRATOR_PROTOCOL_VERSION, + mode: MigratorRequestMode::Execute, + origin: MigratorRequestOrigin::Settings, + run_id: uuid::Uuid::new_v4().to_string(), + nonce: uuid::Uuid::new_v4().to_string(), + selection: MigrationSelection::all(), + caller_process_id: 42, + product_id: "openbitfun".to_string(), + release_channel: "stable".to_string(), + created_at_ms: now_ms, + expires_at_ms: now_ms + 60_000, + required_capabilities: BTreeSet::from([ + MigratorProtocolCapability::OfflineExecute, + MigratorProtocolCapability::JournalRecovery, + ]), + ..MigratorHandoffRequest::default() + } + } + + fn plan(request: &MigratorHandoffRequest) -> MigrationPlan { + MigrationPlan { + format_version: CURRENT_MIGRATION_FORMAT_VERSION, + run_id: request.run_id.clone(), + source_fingerprint: "sha256:fixture".to_string(), + selection: request.selection.clone(), + plan_hash: "sha256:plan".to_string(), + ..MigrationPlan::default() + } + } + + #[test] + fn handoff_nonce_can_only_resume_the_same_persisted_plan() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let roots = roots(temporary.path()); + let store = HandoffStore::new(roots, "openbitfun", "stable"); + let request = request(1_000); + store.write_request(&request, 1_000).expect("write request"); + let handoff = store + .load_request(&request.run_id, 1_001) + .expect("load request"); + assert_eq!(handoff.disposition(), HandoffDisposition::Fresh); + let plan = plan(&request); + assert_eq!( + store + .authorize_plan(&handoff, &plan, 1_002) + .expect("consume nonce"), + HandoffDisposition::Fresh + ); + + let recovered = store + .load_request(&request.run_id, 1_003) + .expect("load recovery request"); + assert_eq!(recovered.disposition(), HandoffDisposition::Recovery); + assert_eq!( + store + .authorize_plan(&recovered, &plan, 1_004) + .expect("resume exact plan"), + HandoffDisposition::Recovery + ); + + let mut different = plan.clone(); + different.plan_hash = "sha256:different".to_string(); + assert!(store.authorize_plan(&recovered, &different, 1_005).is_err()); + } + + #[test] + fn handoff_rejects_wrong_product_channel_and_expiry() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let store = HandoffStore::new(roots(temporary.path()), "openbitfun", "stable"); + let mut wrong_product = request(1_000); + wrong_product.product_id = "other".to_string(); + assert!(store.write_request(&wrong_product, 1_000).is_err()); + + let mut wrong_channel = request(1_000); + wrong_channel.release_channel = "nightly".to_string(); + assert!(store.write_request(&wrong_channel, 1_000).is_err()); + + let expired = request(1_000); + assert!(store.write_request(&expired, 100_000).is_err()); + } + + #[test] + fn process_classifier_keeps_caller_and_known_writers_only() { + let processes = vec![ + ProcessEntry { + process_id: 10, + executable_name: "renamed-caller.exe".to_string(), + }, + ProcessEntry { + process_id: 11, + executable_name: "bitfun-desktop.exe".to_string(), + }, + ProcessEntry { + process_id: 12, + executable_name: "unrelated.exe".to_string(), + }, + ProcessEntry { + process_id: 13, + executable_name: "openbitfun-data-migrator.exe".to_string(), + }, + ]; + let blockers = classify_writer_processes(&processes, 10, 13, &[]); + assert_eq!( + blockers + .iter() + .map(|process| process.process_id) + .collect::>(), + vec![10, 11] + ); + assert!(blockers[0].is_handoff_caller); + } + + #[test] + fn process_classifier_includes_product_projected_writer_names() { + let processes = vec![ProcessEntry { + process_id: 21, + executable_name: platform_binary_filename("acme-desktop"), + }]; + + let blockers = classify_writer_processes(&processes, 99, 100, &["acme-desktop"]); + + assert_eq!(blockers.len(), 1); + assert_eq!(blockers[0].process_id, 21); + assert!(!blockers[0].is_handoff_caller); + } + + struct AllowAll; + + impl ExecutableTrustVerifier for AllowAll { + fn verify(&self, _path: &Path) -> LegacyMigrationResult<()> { + Ok(()) + } + } + + #[test] + fn trusted_resolver_never_accepts_a_request_supplied_target_path() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let current = temporary + .path() + .join(platform_binary_filename("openbitfun-data-migrator")); + let target = temporary + .path() + .join(platform_binary_filename("openbitfun-desktop")); + fs::write(¤t, b"migrator").expect("write current executable"); + fs::write(&target, b"desktop").expect("write target executable"); + + let resolved = TrustedInstallationResolver::resolve_sibling( + ¤t, + "openbitfun-data-migrator", + "openbitfun-desktop", + &AllowAll, + ) + .expect("resolve trusted sibling"); + assert_eq!( + resolved.target_executable(), + fs::canonicalize(target).unwrap() + ); + assert!(TrustedInstallationResolver::resolve_sibling( + ¤t, + "openbitfun-data-migrator", + "../attacker", + &AllowAll, + ) + .is_err()); + } +} diff --git a/src/crates/services/legacy-migration/src/lib.rs b/src/crates/services/legacy-migration/src/lib.rs index 4922affe17..1846a3aef9 100644 --- a/src/crates/services/legacy-migration/src/lib.rs +++ b/src/crates/services/legacy-migration/src/lib.rs @@ -2,6 +2,8 @@ mod engine; mod error; +mod handoff; +mod onboarding; mod paths; mod probe; mod sqlite; @@ -12,6 +14,12 @@ pub use engine::{ LegacyDomainAdapter, MigrationEngine, NoCrashInjection, }; pub use error::{LegacyMigrationError, LegacyMigrationResult}; +pub use handoff::{ + blocking_writer_processes, blocking_writer_processes_for_product, launch_trusted_executable, + ExecutableTrustVerifier, HandoffDisposition, HandoffStore, PlatformExecutableTrustVerifier, + TrustedExecutable, TrustedInstallationResolver, ValidatedHandoff, WriterProcess, +}; +pub use onboarding::MigrationOnboardingStore; pub use paths::{MigrationRoots, LEGACY_PRODUCT_ID}; pub use probe::{probe_legacy_source, ProbeLimits}; pub use sqlite::{snapshot_sqlite_read_only, validate_sqlite}; diff --git a/src/crates/services/legacy-migration/src/onboarding.rs b/src/crates/services/legacy-migration/src/onboarding.rs new file mode 100644 index 0000000000..11c4fd26c7 --- /dev/null +++ b/src/crates/services/legacy-migration/src/onboarding.rs @@ -0,0 +1,283 @@ +use crate::{ + atomic_write_json, LegacyMigrationError, LegacyMigrationResult, MigrationLayout, MigrationRoots, +}; +use openbitfun_product_domains::legacy_migration::{MigrationOnboardingState, MigrationRunReport}; +use serde_json::{Map, Value}; +use std::fs; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; + +const MAX_ONBOARDING_STATE_BYTES: u64 = 64 * 1024; +const MAX_MIGRATION_REPORT_BYTES: u64 = 8 * 1024 * 1024; + +/// Small persisted state that can be read before normal product storage opens. +/// +/// The store intentionally retains unknown JSON fields when current code updates +/// known fields. This allows an older Desktop or Data Migrator to coexist with a +/// newer additive state shape during upgrades. +#[derive(Debug, Clone)] +pub struct MigrationOnboardingStore { + roots: MigrationRoots, +} + +impl MigrationOnboardingStore { + pub fn new(roots: MigrationRoots) -> Self { + Self { roots } + } + + pub fn path(&self) -> PathBuf { + self.roots.migration_root().join("onboarding.json") + } + + pub fn load(&self) -> LegacyMigrationResult { + let (_, state) = self.load_document()?; + Ok(state) + } + + pub fn update( + &self, + update: impl FnOnce(&mut MigrationOnboardingState), + ) -> LegacyMigrationResult { + let (mut document, mut state) = self.load_document()?; + update(&mut state); + let known = serde_json::to_value(&state) + .map_err(|error| LegacyMigrationError::json(self.path(), error))?; + let known = known.as_object().ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "migration onboarding state did not serialize as an object".to_string(), + ) + })?; + document.extend(known.clone()); + atomic_write_json(&self.path(), &Value::Object(document))?; + Ok(state) + } + + /// Consume a restart acknowledgement exactly once. + /// + /// A stale, malformed, or unrelated command-line value never suppresses the + /// legacy probe. The matching id is cleared durably before startup proceeds. + pub fn consume_handled_run_id(&self, run_id: &str) -> LegacyMigrationResult { + if uuid::Uuid::parse_str(run_id).is_err() { + return Ok(false); + } + let current = self.load()?; + if current.handled_run_id.as_deref() != Some(run_id) { + return Ok(false); + } + self.update(|state| state.handled_run_id = None)?; + Ok(true) + } + + pub fn load_report(&self, run_id: &str) -> LegacyMigrationResult> { + if uuid::Uuid::parse_str(run_id).is_err() { + return Err(LegacyMigrationError::InvalidRequest( + "migration report run id must be a UUID".to_string(), + )); + } + let layout = MigrationLayout::new(&self.roots, run_id); + let path = layout.report_path(); + ensure_path_chain_is_plain(layout.root(), &path)?; + let file = match fs::File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(LegacyMigrationError::io(&path, error)), + }; + let mut bytes = Vec::new(); + file.take(MAX_MIGRATION_REPORT_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|error| LegacyMigrationError::io(&path, error))?; + if bytes.len() as u64 > MAX_MIGRATION_REPORT_BYTES { + return Err(LegacyMigrationError::ResourceLimit( + "migration report exceeds the size limit".to_string(), + )); + } + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|error| LegacyMigrationError::json(&path, error)) + } + + pub fn load_last_report(&self) -> LegacyMigrationResult> { + let state = self.load()?; + let Some(run_id) = state.last_report_run_id.as_deref() else { + return Ok(None); + }; + self.load_report(run_id) + } + + fn load_document( + &self, + ) -> LegacyMigrationResult<(Map, MigrationOnboardingState)> { + let path = self.path(); + ensure_path_chain_is_plain(self.roots.migration_root().as_path(), &path)?; + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok((Map::new(), MigrationOnboardingState::default())); + } + Err(error) => return Err(LegacyMigrationError::io(&path, error)), + }; + if is_link_or_reparse(&metadata) { + return Err(LegacyMigrationError::LinkedPath(path)); + } + if metadata.len() > MAX_ONBOARDING_STATE_BYTES { + return Err(LegacyMigrationError::ResourceLimit( + "migration onboarding state exceeds the size limit".to_string(), + )); + } + let bytes = fs::read(&path).map_err(|error| LegacyMigrationError::io(&path, error))?; + let value: Value = serde_json::from_slice(&bytes) + .map_err(|error| LegacyMigrationError::json(&path, error))?; + let document = value.as_object().cloned().ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "migration onboarding state must be a JSON object".to_string(), + ) + })?; + let state = serde_json::from_value(Value::Object(document.clone())) + .map_err(|error| LegacyMigrationError::json(&path, error))?; + Ok((document, state)) + } +} + +fn ensure_path_chain_is_plain(root: &Path, target: &Path) -> LegacyMigrationResult<()> { + let relative = target + .strip_prefix(root) + .map_err(|_| LegacyMigrationError::PathEscape(target.to_path_buf()))?; + let mut current = root.to_path_buf(); + if let Ok(metadata) = fs::symlink_metadata(¤t) { + if is_link_or_reparse(&metadata) { + return Err(LegacyMigrationError::LinkedPath(current)); + } + } + for component in relative.components() { + let Component::Normal(component) = component else { + return Err(LegacyMigrationError::PathEscape(target.to_path_buf())); + }; + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if is_link_or_reparse(&metadata) => { + return Err(LegacyMigrationError::LinkedPath(current)); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(LegacyMigrationError::io(¤t, error)), + } + } + Ok(()) +} + +#[cfg(windows)] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_type().is_symlink() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(test)] +mod tests { + use super::*; + use openbitfun_product_domains::legacy_migration::{MigrationPromptChoice, MigrationRunStatus}; + + fn roots(root: &Path) -> MigrationRoots { + MigrationRoots { + legacy_user_root: root.join("legacy/user"), + legacy_home_root: root.join("legacy/home"), + legacy_skills_root: root.join("legacy/skills"), + legacy_ssh_root: root.join("legacy/ssh"), + target_user_root: root.join("target/user"), + target_home_root: root.join("target/home"), + target_skills_root: root.join("target/skills"), + target_ssh_root: root.join("target/ssh"), + } + } + + #[test] + fn updates_preserve_unknown_future_fields() { + let temporary = tempfile::tempdir().unwrap(); + let store = MigrationOnboardingStore::new(roots(temporary.path())); + fs::create_dir_all(store.path().parent().unwrap()).unwrap(); + fs::write(&store.path(), br#"{"futureField":{"keep":true}}"#).unwrap(); + + store + .update(|state| { + state.choice = MigrationPromptChoice::RemindLater; + state.run_id = Some("run-id".to_string()); + }) + .unwrap(); + + let value: Value = serde_json::from_slice(&fs::read(store.path()).unwrap()).unwrap(); + assert_eq!(value["futureField"]["keep"], true); + assert_eq!(value["choice"], "remind_later"); + assert_eq!(value["runId"], "run-id"); + } + + #[test] + fn restart_acknowledgement_is_consumed_only_once() { + let temporary = tempfile::tempdir().unwrap(); + let store = MigrationOnboardingStore::new(roots(temporary.path())); + let run_id = uuid::Uuid::new_v4().to_string(); + store + .update(|state| state.handled_run_id = Some(run_id.clone())) + .unwrap(); + + assert!(store.consume_handled_run_id(&run_id).unwrap()); + assert!(!store.consume_handled_run_id(&run_id).unwrap()); + assert!(!store.consume_handled_run_id("not-a-uuid").unwrap()); + } + + #[test] + fn last_report_uses_a_distinct_persisted_run_reference() { + let temporary = tempfile::tempdir().unwrap(); + let roots = roots(temporary.path()); + let store = MigrationOnboardingStore::new(roots.clone()); + let report_run_id = uuid::Uuid::new_v4().to_string(); + let current_run_id = uuid::Uuid::new_v4().to_string(); + let layout = MigrationLayout::new(&roots, &report_run_id); + layout.initialize().unwrap(); + atomic_write_json( + &layout.report_path(), + &MigrationRunReport { + run_id: report_run_id.clone(), + status: MigrationRunStatus::Completed, + ..MigrationRunReport::default() + }, + ) + .unwrap(); + store + .update(|state| { + state.run_id = Some(current_run_id); + state.last_report_run_id = Some(report_run_id.clone()); + }) + .unwrap(); + + assert_eq!( + store.load_last_report().unwrap().unwrap().run_id, + report_run_id + ); + } + + #[test] + fn oversized_report_is_rejected_before_deserialization() { + let temporary = tempfile::tempdir().unwrap(); + let roots = roots(temporary.path()); + let store = MigrationOnboardingStore::new(roots.clone()); + let run_id = uuid::Uuid::new_v4().to_string(); + let layout = MigrationLayout::new(&roots, &run_id); + layout.initialize().unwrap(); + fs::write( + layout.report_path(), + vec![b' '; MAX_MIGRATION_REPORT_BYTES as usize + 1], + ) + .unwrap(); + + assert!(matches!( + store.load_report(&run_id), + Err(LegacyMigrationError::ResourceLimit(_)) + )); + } +} diff --git a/src/crates/services/legacy-migration/src/probe.rs b/src/crates/services/legacy-migration/src/probe.rs index 5562a58384..f0d76b3c43 100644 --- a/src/crates/services/legacy-migration/src/probe.rs +++ b/src/crates/services/legacy-migration/src/probe.rs @@ -1,7 +1,10 @@ -use crate::{LegacyMigrationError, LegacyMigrationResult, MigrationRoots, LEGACY_PRODUCT_ID}; +use crate::{ + LegacyMigrationError, LegacyMigrationResult, MigrationOnboardingStore, MigrationRoots, + LEGACY_PRODUCT_ID, +}; use openbitfun_product_domains::legacy_migration::{ FindingSeverity, LegacyRootDescriptor, LegacyRootKind, LegacySourceDescriptor, - MigrationDiagnostic, + MigrationDiagnostic, MigrationPromptChoice, MigrationRunStatus, }; use semver::{Version, VersionReq}; use serde_json::Value; @@ -69,7 +72,7 @@ pub fn probe_legacy_source( } let fingerprint = format!("sha256:{}", hex::encode(hasher.finalize())); let source_id = format!("bitfun-{}", &fingerprint[7..23]); - let already_migrated = source_was_migrated(roots, &fingerprint); + let already_migrated = source_was_migrated(roots, &fingerprint)?; let mut diagnostics = Vec::new(); if version.is_none() { diagnostics.push(MigrationDiagnostic { @@ -291,14 +294,17 @@ fn hash_path_fact(hasher: &mut Sha256, root: &Path, path: &Path) -> LegacyMigrat Ok(()) } -fn source_was_migrated(roots: &MigrationRoots, fingerprint: &str) -> bool { - let path = roots.migration_root().join("onboarding.json"); - let Ok(bytes) = fs::read(path) else { - return false; - }; - serde_json::from_slice::(&bytes) - .ok() - .and_then(|value| value.get("sourceFingerprint").cloned()) - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .is_some_and(|value| value == fingerprint) +fn source_was_migrated(roots: &MigrationRoots, fingerprint: &str) -> LegacyMigrationResult { + let store = MigrationOnboardingStore::new(roots.clone()); + let state = store.load()?; + if state.source_fingerprint != fingerprint || state.choice != MigrationPromptChoice::MigrateNow + { + return Ok(false); + } + Ok(store.load_last_report()?.is_some_and(|report| { + matches!( + report.status, + MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings + ) + })) } diff --git a/src/crates/services/legacy-migration/src/storage.rs b/src/crates/services/legacy-migration/src/storage.rs index c94a0cd105..ca2bc3de10 100644 --- a/src/crates/services/legacy-migration/src/storage.rs +++ b/src/crates/services/legacy-migration/src/storage.rs @@ -31,6 +31,10 @@ impl MigrationLayout { self.run_root().join("request.json") } + pub fn consumed_nonce_path(&self) -> PathBuf { + self.run_root().join("nonce-consumed.json") + } + pub fn plan_path(&self) -> PathBuf { self.run_root().join("plan.json") } diff --git a/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs b/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs index 97da5717de..200f138b66 100644 --- a/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs +++ b/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs @@ -6,7 +6,7 @@ use openbitfun_legacy_migration::{ }; use openbitfun_product_domains::legacy_migration::{ FindingSeverity, MigrationDiagnostic, MigrationDomainId, MigrationDomainResult, - MigrationDomainState, MigrationGroupId, MigrationRunReport, MigrationRunStatus, + MigrationDomainState, MigrationGroupId, MigrationPhase, MigrationRunReport, MigrationRunStatus, MigrationSelection, ScanFinding, }; use rusqlite::Connection; @@ -279,6 +279,59 @@ fn engine_persists_owner_metadata_finalized_after_commit_validation() { assert_eq!(persisted, report); } +#[test] +fn progress_reports_real_domain_phases_counts_and_cancel_boundaries() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + seed_supported_source(&roots); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .expect("probe should succeed") + .expect("source should be present"); + let engine = fake_engine(roots, Arc::new(Mutex::new(BTreeMap::new()))); + let plan = engine + .plan( + &source, + MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }, + &CancellationToken::default(), + ) + .expect("plan should succeed"); + let mut progress = Vec::new(); + + engine + .execute_with_progress( + &plan, + &CancellationToken::default(), + &NoCrashInjection, + |event| progress.push(event), + ) + .expect("execution should succeed"); + + for phase in [ + MigrationPhase::Stage, + MigrationPhase::ValidateStage, + MigrationPhase::Commit, + MigrationPhase::ValidateCommit, + MigrationPhase::Finalize, + ] { + assert!( + progress.iter().any(|event| event.phase == phase), + "progress should include {phase:?}" + ); + } + assert!(progress + .iter() + .filter(|event| event.phase == MigrationPhase::Commit) + .all(|event| !event.safe_to_cancel)); + assert!(progress + .iter() + .any(|event| event.code == "domain_verified" && event.safe_to_cancel)); + assert!(progress + .iter() + .all(|event| event.processed <= event.total && event.total == plan.steps.len() as u64)); +} + #[test] fn engine_rolls_back_unverified_commit_when_owner_finalization_fails() { let temp = test_tempdir(); diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index ab1761509c..89ea3c489e 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -120,6 +120,7 @@ process-runtime = [ "tokio/time", "windows/Win32_Foundation", "windows/Win32_System_Diagnostics_ToolHelp", + "windows/Win32_System_JobObjects", "windows/Win32_System_Threading", ] workspace-instructions = [ diff --git a/src/crates/services/services-core/src/process_manager.rs b/src/crates/services/services-core/src/process_manager.rs index 22e9ab4b5b..ad88966cef 100644 --- a/src/crates/services/services-core/src/process_manager.rs +++ b/src/crates/services/services-core/src/process_manager.rs @@ -4,7 +4,7 @@ //! only closes managed child trees; it must never close a Job containing the //! calling host before an updater, restart, or shutdown can finish. -use std::process::Command; +use std::process::{Command, Stdio}; #[cfg(windows)] use std::sync::LazyLock; #[cfg(target_os = "macos")] @@ -25,6 +25,10 @@ use win32job::Job; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; +#[cfg(windows)] +const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; +#[cfg(windows)] +const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x01000000; #[cfg(windows)] static GLOBAL_PROCESS_MANAGER: LazyLock = LazyLock::new(ProcessManager::new); @@ -62,6 +66,7 @@ impl ProcessManager { let mut info = ExtendedLimitInfo::new(); info.limit_kill_on_job_close(); job.set_extended_limit_info(&info)?; + allow_explicit_job_breakaway(&job)?; // Assign current process to Job so child processes inherit automatically job.assign_current_process()?; @@ -79,6 +84,43 @@ impl ProcessManager { } } +#[cfg(windows)] +fn allow_explicit_job_breakaway(job: &Job) -> Result<(), Box> { + use std::ffi::c_void; + use std::mem::size_of; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::System::JobObjects::{ + JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_BREAKAWAY_OK, + }; + + let handle = HANDLE(job.handle() as *mut c_void); + let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + // SAFETY: `job` owns a live Job handle and `info` is a correctly sized, + // writable buffer for the requested information class. + unsafe { + QueryInformationJobObject( + Some(handle), + JobObjectExtendedLimitInformation, + &mut info as *mut _ as *mut c_void, + size_of::() as u32, + None, + )?; + } + info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_BREAKAWAY_OK; + // SAFETY: the same live Job handle and initialized information buffer are + // passed with their exact byte size. + unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &info as *const _ as *const c_void, + size_of::() as u32, + )?; + } + Ok(()) +} + /// Create synchronous Command (Windows automatically adds CREATE_NO_WINDOW) pub fn create_command>(program: S) -> Command { let cmd = Command::new(program.as_ref()); @@ -116,6 +158,30 @@ pub fn create_tokio_command>(program: S) -> TokioComma cmd } +/// Create a command that must survive the current GUI process exiting. +/// +/// This is reserved for signed, product-owned process handoffs such as the +/// offline Data Migrator and the trusted main-app restart. Callers must resolve +/// the executable from an authenticated installation boundary; this helper +/// only supplies lifecycle and no-console-window behavior. +pub fn create_detached_command>(program: S) -> Command { + let mut command = Command::new(program.as_ref()); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + #[cfg(windows)] + command.creation_flags(detached_creation_flags()); + + command +} + +#[cfg(windows)] +const fn detached_creation_flags() -> u32 { + CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB +} + #[cfg(target_os = "macos")] fn apply_cached_macos_path(cmd: &mut TokioCommand) { if let Some(path) = cached_macos_path_env() { @@ -180,3 +246,16 @@ pub fn contain_current_process_tree() -> std::io::Result<()> { } Ok(()) } + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + #[test] + fn detached_handoff_is_hidden_and_can_leave_the_process_job() { + let flags = detached_creation_flags(); + assert_ne!(flags & CREATE_NO_WINDOW, 0); + assert_ne!(flags & CREATE_NEW_PROCESS_GROUP, 0); + assert_ne!(flags & CREATE_BREAKAWAY_FROM_JOB, 0); + } +} diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 1e24bb24d2..dc31cd938d 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -9125,6 +9125,140 @@ "operations": [], "options": [] }, + { + "id": "setting.data.migration", + "kind": "setting", + "categoryId": "data", + "titleZh": "旧版数据迁移", + "titleEn": "Legacy data migration", + "summaryZh": "从本机旧版 BitFun 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", + "summaryEn": "Scan and import supported data from a local legacy BitFun installation, inspect redacted reports, and leave the legacy source unchanged.", + "keywordsZh": [ + "旧版数据迁移", + "BitFun 数据", + "迁移报告", + "导入旧数据", + "Data Migrator" + ], + "keywordsEn": [ + "legacy data migration", + "BitFun data", + "migration report", + "import old data", + "Data Migrator" + ], + "highlightsZh": [ + "只读扫描本机旧版 BitFun 数据", + "按五个高层数据组选择迁移范围", + "通过独立 Data Migrator 导入并查看去敏报告" + ], + "highlightsEn": [ + "Read-only scan of local legacy BitFun data", + "Choose migration scope across five high-level data groups", + "Import through the standalone Data Migrator and inspect redacted reports" + ], + "items": [ + { + "id": "scan", + "titleZh": "只读扫描本机旧版 BitFun 来源及所选数据组", + "titleEn": "Read-only scan the local legacy BitFun source and selected data groups", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." + }, + "evidence": [ + "command:get_legacy_migration_status", + "command:scan_legacy_migration", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#actions.scan" + ] + }, + { + "id": "scope", + "titleZh": "选择设置、扩展、会话、记忆和远程连接迁移范围", + "titleEn": "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "control": { + "kind": "open", + "reasonCode": "visualSelection", + "reasonZh": "迁移范围是影响本机持久数据的五组可见选择;Agent 会打开精确入口,由用户确认所需范围。", + "reasonEn": "Migration scope is a visible five-group selection affecting local persisted data; the Agent opens the exact entry so the user can confirm the intended scope." + }, + "evidence": [ + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#sections.scope.title" + ] + }, + { + "id": "launch", + "titleZh": "确认关闭影响后启动独立 Data Migrator", + "titleEn": "Launch the standalone Data Migrator after confirming shutdown impact", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." + }, + "evidence": [ + "command:prepare_legacy_migration", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#confirm.title" + ] + }, + { + "id": "report", + "titleZh": "查看最近运行结果和各领域去敏状态", + "titleEn": "Inspect the latest run result and redacted per-domain status", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." + }, + "evidence": [ + "command:get_legacy_migration_report", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#sections.report.title" + ] + }, + { + "id": "reminder", + "titleZh": "恢复已关闭的首次启动迁移提醒", + "titleEn": "Restore the first-start migration reminder after it was disabled", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." + }, + "evidence": [ + "command:set_legacy_migration_prompt_preference", + "source:src/web-ui/src/locales/zh-CN/settings/legacy-migration.json#actions.restoreReminder" + ] + } + ], + "stepsZh": [ + "打开设置", + "进入“数据 > 旧版数据迁移”", + "扫描来源、选择范围并在确认关闭影响后启动迁移器" + ], + "stepsEn": [ + "Open Settings", + "Go to Data > Legacy data migration", + "Scan the source, choose scope, and launch the migrator after confirming shutdown impact" + ], + "agentExamplesZh": [ + "打开旧版数据迁移", + "带我查看 BitFun 迁移报告" + ], + "agentExamplesEn": [ + "Open legacy data migration", + "Show me the BitFun migration report" + ], + "destination": { + "kind": "settings", + "pageId": "data.migration" + }, + "operations": [], + "options": [] + }, { "id": "setting.data.diagnostics", "kind": "setting", @@ -9473,6 +9607,9 @@ "debug": { "capabilityId": "setting.data.diagnostics" }, + "legacy_migration": { + "capabilityId": "setting.data.migration" + }, "appearance": { "capabilityId": "feature.desktop-pet" }, diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 0b250bf543..1a5fe5c7e7 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -38,6 +38,7 @@ import { activateCreationRuntime } from '@/infrastructure/creation/creationRunti import { attachCreationRuntime, recordCreationActivationError } from '@/infrastructure/creation/creationBridge'; import { createCreationUiApi } from './creation/creationUiApi'; import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { showLegacyMigrationStartupNotification } from './startup/legacyMigrationStartupNotification'; const log = createLogger('App'); @@ -872,6 +873,13 @@ function App() { // Debug inspector shortcuts (desktop devtools only) useDebugInspector(); + useEffect(() => { + if (!isTauriRuntime() || !interactiveShellReady) return; + void showLegacyMigrationStartupNotification().catch((error) => { + log.warn('Failed to show legacy migration startup result', error); + }); + }, [interactiveShellReady]); + useEffect(() => { if (!isTauriRuntime() || !interactiveShellReady) { return; diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index a8c4149de3..403e22e76f 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "d7a7419ddd673eb733ae8bd33dbb3dcd3b4d1ce0acfc953067a3403fe26d6699", + "digest": "cf1a4fae19862b0613cb1144cead8fcaaa7559b47d5a5f0f3a2c361be991083b", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -136,13 +136,13 @@ ], "counts": { "features": 22, - "settings": 21, - "userFacing": 43, - "documentedItems": 321, + "settings": 22, + "userFacing": 44, + "documentedItems": 326, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 212, + "interactive": 217, "unsupported": 0 } }, @@ -18765,6 +18765,285 @@ "pageId": "data.archived" } }, + { + "id": "setting.data.migration:query", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scan", + "scope", + "launch", + "report", + "reminder" + ], + "kind": "query", + "risk": "read", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:scan", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scan" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:scope", + "capabilityId": "setting.data.migration", + "itemIds": [ + "scope" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "visualSelection", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:launch", + "capabilityId": "setting.data.migration", + "itemIds": [ + "launch" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:report", + "capabilityId": "setting.data.migration", + "itemIds": [ + "report" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, + { + "id": "setting.data.migration:open:reminder", + "capabilityId": "setting.data.migration", + "itemIds": [ + "reminder" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "settings", + "pageId": "data.migration" + } + }, { "id": "setting.data.diagnostics:query", "capabilityId": "setting.data.diagnostics", @@ -29565,6 +29844,156 @@ ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.archived/" }, + { + "id": "setting.data.migration", + "kind": "setting", + "categoryId": "data", + "titleZh": "旧版数据迁移", + "titleEn": "Legacy data migration", + "summaryZh": "从本机旧版 BitFun 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", + "summaryEn": "Scan and import supported data from a local legacy BitFun installation, inspect redacted reports, and leave the legacy source unchanged.", + "keywordsZh": [ + "旧版数据迁移", + "BitFun 数据", + "迁移报告", + "导入旧数据", + "Data Migrator" + ], + "keywordsEn": [ + "legacy data migration", + "BitFun data", + "migration report", + "import old data", + "Data Migrator" + ], + "highlightsZh": [ + "只读扫描本机旧版 BitFun 数据", + "按五个高层数据组选择迁移范围", + "通过独立 Data Migrator 导入并查看去敏报告" + ], + "highlightsEn": [ + "Read-only scan of local legacy BitFun data", + "Choose migration scope across five high-level data groups", + "Import through the standalone Data Migrator and inspect redacted reports" + ], + "items": [ + { + "id": "scan", + "titleZh": "只读扫描本机旧版 BitFun 来源及所选数据组", + "titleEn": "Read-only scan the local legacy BitFun source and selected data groups", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." + } + }, + { + "id": "scope", + "titleZh": "选择设置、扩展、会话、记忆和远程连接迁移范围", + "titleEn": "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "control": { + "kind": "open", + "reasonCode": "visualSelection", + "reasonZh": "迁移范围是影响本机持久数据的五组可见选择;Agent 会打开精确入口,由用户确认所需范围。", + "reasonEn": "Migration scope is a visible five-group selection affecting local persisted data; the Agent opens the exact entry so the user can confirm the intended scope." + } + }, + { + "id": "launch", + "titleZh": "确认关闭影响后启动独立 Data Migrator", + "titleEn": "Launch the standalone Data Migrator after confirming shutdown impact", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." + } + }, + { + "id": "report", + "titleZh": "查看最近运行结果和各领域去敏状态", + "titleEn": "Inspect the latest run result and redacted per-domain status", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." + } + }, + { + "id": "reminder", + "titleZh": "恢复已关闭的首次启动迁移提醒", + "titleEn": "Restore the first-start migration reminder after it was disabled", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." + } + } + ], + "stepsZh": [ + "打开设置", + "进入“数据 > 旧版数据迁移”", + "扫描来源、选择范围并在确认关闭影响后启动迁移器" + ], + "stepsEn": [ + "Open Settings", + "Go to Data > Legacy data migration", + "Scan the source, choose scope, and launch the migrator after confirming shutdown impact" + ], + "agentExamplesZh": [ + "打开旧版数据迁移", + "带我查看 BitFun 迁移报告" + ], + "agentExamplesEn": [ + "Open legacy data migration", + "Show me the BitFun migration report" + ], + "destination": { + "kind": "settings", + "pageId": "data.migration" + }, + "operations": [], + "options": [], + "searchTerms": [ + "setting.data.migration", + "旧版数据迁移", + "Legacy data migration", + "数据与诊断", + "Data & diagnostics", + "BitFun 数据", + "迁移报告", + "导入旧数据", + "Data Migrator", + "legacy data migration", + "BitFun data", + "migration report", + "import old data", + "只读扫描本机旧版 BitFun 数据", + "按五个高层数据组选择迁移范围", + "通过独立 Data Migrator 导入并查看去敏报告", + "Read-only scan of local legacy BitFun data", + "Choose migration scope across five high-level data groups", + "Import through the standalone Data Migrator and inspect redacted reports", + "只读扫描本机旧版 BitFun 来源及所选数据组", + "Read-only scan the local legacy BitFun source and selected data groups", + "选择设置、扩展、会话、记忆和远程连接迁移范围", + "Choose settings, extensions, sessions, memory, and remote-connection migration scope", + "确认关闭影响后启动独立 Data Migrator", + "Launch the standalone Data Migrator after confirming shutdown impact", + "查看最近运行结果和各领域去敏状态", + "Inspect the latest run result and redacted per-domain status", + "恢复已关闭的首次启动迁移提醒", + "Restore the first-start migration reminder after it was disabled", + "打开旧版数据迁移", + "带我查看 BitFun 迁移报告", + "Open legacy data migration", + "Show me the BitFun migration report" + ], + "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.migration/" + }, { "id": "setting.data.diagnostics", "kind": "setting", diff --git a/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts b/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts index 7dd3066e23..ce7458ab48 100644 --- a/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts +++ b/src/web-ui/src/app/scenes/settings/settingsRegistry.test.ts @@ -15,7 +15,7 @@ vi.mock('@/infrastructure/i18n/core/I18nService', () => ({ })); describe('settings information architecture', () => { - it('uses five ownership categories and twenty canonical pages', () => { + it('uses five ownership categories and twenty-one canonical pages', () => { expect(SETTINGS_CATEGORIES.map((category) => category.id)).toEqual([ 'application', 'ai', @@ -23,8 +23,8 @@ describe('settings information architecture', () => { 'tools', 'data', ]); - expect(SETTINGS_PAGE_MANIFESTS).toHaveLength(20); - expect(new Set(SETTINGS_PAGE_MANIFESTS.map((page) => page.id)).size).toBe(20); + expect(SETTINGS_PAGE_MANIFESTS).toHaveLength(21); + expect(new Set(SETTINGS_PAGE_MANIFESTS.map((page) => page.id)).size).toBe(21); }); it('keeps memory with AI, pet with application, and review inside execution', () => { @@ -142,10 +142,13 @@ describe('settings information architecture', () => { expect(dataPages?.map((page) => page.id)).toEqual([ 'data.usage', 'data.archived', + 'data.migration', 'data.diagnostics', ]); expect(SETTINGS_PAGE_MANIFESTS.find((page) => page.id === 'data.usage')?.views).toBeUndefined(); expect(SETTINGS_PAGE_MANIFESTS.find((page) => page.id === 'data.archived')?.views).toBeUndefined(); + expect(SETTINGS_PAGE_MANIFESTS.find((page) => page.id === 'data.migration')?.namespaces) + .toEqual(['settings/legacy-migration']); }); it('contains old links at the upgrade boundary and emits canonical destinations', () => { diff --git a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts index bec302602d..845398857b 100644 --- a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts @@ -377,6 +377,21 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [ ], load: () => import('./components/ArchivedSessionsConfig'), }), + definePage({ + id: 'data.migration', + categoryId: 'data', + labelKey: 'navigation.pages.legacyMigration.label', + descriptionKey: 'navigation.pages.legacyMigration.description', + keywords: ['legacy', 'migration', 'import', 'bitfun', 'upgrade', 'maintenance'], + namespaces: ['settings/legacy-migration'], + searchPhrases: [ + phrase('settings/legacy-migration', 'title'), + phrase('settings/legacy-migration', 'subtitle'), + phrase('settings/legacy-migration', 'sections.source.title'), + phrase('settings/legacy-migration', 'sections.report.title'), + ], + load: () => import('../../../infrastructure/config/components/LegacyMigrationSettingsPage'), + }), definePage({ id: 'data.diagnostics', categoryId: 'data', diff --git a/src/web-ui/src/app/scenes/settings/settingsTypes.ts b/src/web-ui/src/app/scenes/settings/settingsTypes.ts index 848833b8b5..8caa196a31 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTypes.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTypes.ts @@ -25,6 +25,7 @@ export type SettingsPageId = | 'tools.acp' | 'data.usage' | 'data.archived' + | 'data.migration' | 'data.diagnostics'; export type SettingsViewId = diff --git a/src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts new file mode 100644 index 0000000000..9e189063af --- /dev/null +++ b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { showLegacyMigrationStartupNotification } from './legacyMigrationStartupNotification'; + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + loadNamespace: vi.fn(), + t: vi.fn((key: string) => key), + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + info: vi.fn(), +})); + +vi.mock('@/infrastructure/api/service-api/LegacyMigrationAPI', () => ({ + legacyMigrationAPI: { getStatus: mocks.getStatus }, +})); +vi.mock('@/infrastructure/i18n', () => ({ + i18nService: { loadNamespace: mocks.loadNamespace, t: mocks.t }, +})); +vi.mock('@/shared/notification-system', () => ({ + notificationService: { + success: mocks.success, + warning: mocks.warning, + error: mocks.error, + info: mocks.info, + }, +})); + +describe('legacy migration startup notification', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadNamespace.mockResolvedValue(undefined); + }); + + it('loads the lazy namespace and shows a completed report once', async () => { + mocks.getStatus.mockResolvedValue({ startupReport: { runId: 'run-1', status: 'completed' } }); + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); }, + }; + + await showLegacyMigrationStartupNotification(storage); + await showLegacyMigrationStartupNotification(storage); + + expect(mocks.loadNamespace).toHaveBeenCalledWith('settings/legacy-migration'); + expect(mocks.success).toHaveBeenCalledTimes(1); + expect(mocks.success.mock.calls[0][1].metadata).toEqual({ + source: 'legacy-migration-startup-result', + runId: 'run-1', + status: 'completed', + }); + }); +}); diff --git a/src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts new file mode 100644 index 0000000000..c169629eef --- /dev/null +++ b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts @@ -0,0 +1,46 @@ +import { legacyMigrationAPI, type MigrationRunStatus } from '@/infrastructure/api/service-api/LegacyMigrationAPI'; +import { i18nService } from '@/infrastructure/i18n'; +import { notificationService } from '@/shared/notification-system'; + +function notificationKind(status: MigrationRunStatus): 'success' | 'warning' | 'error' | 'info' { + if (status === 'completed') return 'success'; + if (status === 'completed_with_warnings' || status === 'cancelled') return 'warning'; + if (status.startsWith('failed_')) return 'error'; + return 'info'; +} + +export async function showLegacyMigrationStartupNotification( + storage: Pick = sessionStorage, +): Promise { + const status = await legacyMigrationAPI.getStatus(); + const report = status.startupReport; + if (!report) return; + + const noticeKey = `openbitfun:legacy-migration-notice:${report.runId}`; + if (storage.getItem(noticeKey) === 'shown') return; + + await i18nService.loadNamespace('settings/legacy-migration'); + storage.setItem(noticeKey, 'shown'); + const namespace = 'settings/legacy-migration'; + const openReport = () => { + void import('@/shared/services/ide-control').then(({ quickActions }) => { + quickActions.openSettings({ pageId: 'data.migration' }); + }); + }; + const options = { + title: i18nService.t(`${namespace}:startupNotification.title`), + duration: 0, + actions: [{ + label: i18nService.t(`${namespace}:actions.viewReport`), + variant: 'primary' as const, + onClick: openReport, + }], + metadata: { + source: 'legacy-migration-startup-result', + runId: report.runId, + status: report.status, + }, + }; + const message = i18nService.t(`${namespace}:startupNotification.statuses.${report.status}`); + notificationService[notificationKind(report.status)](message, options); +} diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index e7bf7973cb..b73fa5b4ce 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,7 +1,7 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "d7a7419ddd673eb733ae8bd33dbb3dcd3b4d1ce0acfc953067a3403fe26d6699" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "cf1a4fae19862b0613cb1144cead8fcaaa7559b47d5a5f0f3a2c361be991083b" as const; -export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; +export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.migration" | "setting.data.diagnostics"; export interface ProductControlOptionIdsByCapability { "feature.ai-assistant": never; @@ -46,6 +46,7 @@ export interface ProductControlOptionIdsByCapability { "setting.tools.acp": never; "setting.data.usage": never; "setting.data.archived": never; + "setting.data.migration": never; "setting.data.diagnostics": "log-level" | "sensitive-diagnostics"; } @@ -92,6 +93,7 @@ export interface ProductControlOperationIdsByCapability { "setting.tools.acp": never; "setting.data.usage": never; "setting.data.archived": never; + "setting.data.migration": never; "setting.data.diagnostics": never; } diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index 4b94ff2c3b..39c35913da 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:4cdcd7ff4dcd85a9" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:f5390eecb4710d17" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller @@ -83,6 +83,8 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "get_announcement_tips", "get_frontend_update_status", "get_latest_insights", + "get_legacy_migration_report", + "get_legacy_migration_status", "get_pending_announcements", "get_pending_update", "get_prevent_sleep_enabled", @@ -112,6 +114,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "peer_controller_set_active", "peer_host_invoke_complete", "peer_mode_ping", + "prepare_legacy_migration", "quit_app", "relay_deploy_cancel", "relay_deploy_install_docker", @@ -142,6 +145,8 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "resolve_browser_dropped_file_paths", "restart_app", "rollback_frontend_update", + "scan_legacy_migration", + "set_legacy_migration_prompt_preference", "set_main_window_transient_geometry", "set_prevent_sleep_enabled", "show_agent_companion_desktop_pet", diff --git a/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.test.ts new file mode 100644 index 0000000000..7d4e19c54c --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LegacyMigrationAPI } from './LegacyMigrationAPI'; + +const invokeMock = vi.hoisted(() => vi.fn()); + +vi.mock('./ApiClient', () => ({ + api: { invoke: invokeMock }, +})); + +describe('LegacyMigrationAPI', () => { + beforeEach(() => invokeMock.mockReset()); + + it('uses structured requests through the active ApiClient transport', async () => { + invokeMock.mockResolvedValue({ runId: 'run-1', mode: 'execute' }); + const selection = { groups: ['memory'] as const }; + + await new LegacyMigrationAPI().prepare({ groups: [...selection.groups] }); + + expect(invokeMock).toHaveBeenCalledWith('prepare_legacy_migration', { + request: { selection: { groups: ['memory'] } }, + }); + }); + + it('does not expose an in-process execute command', () => { + const migration = new LegacyMigrationAPI() as unknown as Record; + + expect(migration.execute).toBeUndefined(); + expect(migration.run).toBeUndefined(); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.ts b/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.ts new file mode 100644 index 0000000000..3f89545462 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.ts @@ -0,0 +1,177 @@ +import { createTauriCommandError } from '../errors/TauriCommandError'; +import { api } from './ApiClient'; + +export type MigrationGroupId = + | 'settings_and_credentials' + | 'agents_skills_and_miniapps' + | 'workspaces_sessions_and_tasks' + | 'memory' + | 'remote_connections_and_devices'; + +export type MigrationDomainId = + | 'settings' + | 'credentials' + | 'skills' + | 'miniapps' + | 'agents' + | 'workspace_sessions' + | 'agent_coordination' + | 'structured_memory' + | 'file_memory' + | 'remote_connect_devices' + | 'remote_ssh' + | 'cross_reference_repair'; + +export type MigrationPromptChoice = + | 'unset' + | 'migrate_now' + | 'remind_later' + | 'do_not_remind'; + +export type MigrationRunStatus = + | 'discovered' + | 'scanned' + | 'planned' + | 'waiting_for_processes' + | 'staging' + | 'validating_stage' + | 'committing' + | 'validating_commit' + | 'completed' + | 'completed_with_warnings' + | 'cancelled' + | 'failed_recoverable' + | 'failed_manual_action_required'; + +export type MigrationDomainState = + | 'not_started' + | 'staged' + | 'committed' + | 'verified' + | 'failed' + | 'skipped'; + +export interface MigrationSelection { + groups: MigrationGroupId[]; +} + +export interface MigrationDiagnostic { + code: string; + severity: 'info' | 'warning' | 'blocking'; + domain: MigrationDomainId | null; + relativePath: string | null; + message: string; + action: string | null; +} + +export interface LegacySourceDescriptor { + sourceId: string; + sourceFingerprint: string; + productId: string; + productVersion: string; + platform: string; + readable: boolean; + supported: boolean; + approximateBytes: number; + alreadyMigrated: boolean; + diagnostics: MigrationDiagnostic[]; +} + +export interface MigrationOnboardingState { + formatVersion: number; + sourceFingerprint: string; + detectedAtMs: number | null; + lastScannedAtMs: number | null; + choice: MigrationPromptChoice; + lastPromptedVersion: string | null; + runId: string | null; + lastReportRunId: string | null; + handledRunId: string | null; +} + +export interface MigrationDomainResult { + domain: MigrationDomainId; + state: MigrationDomainState; + imported: number; + skipped: number; + conflicts: number; + warnings: MigrationDiagnostic[]; + requiresReauthentication: string[]; + requiresRelocation: string[]; +} + +export interface MigrationRunReport { + formatVersion: number; + runId: string; + sourceFingerprint: string; + planHash: string; + status: MigrationRunStatus; + startedAtMs: number; + finishedAtMs: number | null; + domainResults: MigrationDomainResult[]; + diagnostics: MigrationDiagnostic[]; + requiresReauthentication: string[]; + requiresRelocation: string[]; +} + +export interface LegacyMigrationStatusView { + source: LegacySourceDescriptor | null; + onboarding: MigrationOnboardingState; + latestReport: MigrationRunReport | null; + startupReport: MigrationRunReport | null; +} + +export interface ScanFinding { + domain: MigrationDomainId; + code: string; + severity: 'info' | 'warning' | 'blocking'; + entityCount: number; + logicalBytes: number; + sourceSchema: string | null; + migratable: boolean; + detail: string; +} + +export interface LegacyMigrationScanView { + source: LegacySourceDescriptor; + selection: MigrationSelection; + scannedAtMs: number; + findings: ScanFinding[]; +} + +export interface LegacyMigrationHandoffView { + runId: string; + mode: 'onboarding' | 'execute'; +} + +export class LegacyMigrationAPI { + private async invoke(command: string, request: object): Promise { + try { + return await api.invoke(command, { request }); + } catch (error) { + throw createTauriCommandError(command, error, request); + } + } + + getStatus(): Promise { + return this.invoke('get_legacy_migration_status', {}); + } + + scan(selection?: MigrationSelection): Promise { + return this.invoke('scan_legacy_migration', selection ? { selection } : {}); + } + + prepare(selection: MigrationSelection): Promise { + return this.invoke('prepare_legacy_migration', { selection }); + } + + getReport(runId?: string): Promise { + return this.invoke('get_legacy_migration_report', runId ? { runId } : {}); + } + + setPromptPreference(choice: MigrationPromptChoice): Promise { + return this.invoke('set_legacy_migration_prompt_preference', { choice }); + } +} + +export const legacyMigrationAPI = new LegacyMigrationAPI(); diff --git a/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.scss b/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.scss new file mode 100644 index 0000000000..38106e38c8 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.scss @@ -0,0 +1,20 @@ +.openbitfun-legacy-migration__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--openbitfun-space-2); +} + +.openbitfun-legacy-migration__value { + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-sm-font-size); + overflow-wrap: anywhere; + text-align: end; +} + +.openbitfun-legacy-migration__impact-list { + display: grid; + gap: var(--openbitfun-space-1); + margin: 0; + padding-inline-start: var(--openbitfun-space-5); +} diff --git a/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.tsx b/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.tsx new file mode 100644 index 0000000000..a4640e908d --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.tsx @@ -0,0 +1,410 @@ +import { Button, Checkbox, StatusPill, type StatusPillTone } from '@openbitfun/ui'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { confirmDialog } from '@/infrastructure/confirm-dialog'; +import { useI18n } from '@/infrastructure/i18n'; +import { + legacyMigrationAPI, + type LegacyMigrationScanView, + type LegacyMigrationStatusView, + type MigrationDomainId, + type MigrationDomainState, + type MigrationGroupId, + type MigrationRunReport, + type MigrationRunStatus, + type MigrationSelection, +} from '@/infrastructure/api/service-api/LegacyMigrationAPI'; +import { useNotification } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { + ConfigLoadingState, + ConfigMessage, + ConfigPageContent, + ConfigPageHeader, + ConfigPageLayout, + ConfigPageRow, + ConfigPageSection, + ConfigRetryState, +} from './common'; +import './LegacyMigrationSettingsPage.scss'; + +const log = createLogger('LegacyMigrationSettings'); + +const MIGRATION_GROUPS: readonly MigrationGroupId[] = [ + 'settings_and_credentials', + 'agents_skills_and_miniapps', + 'workspaces_sessions_and_tasks', + 'memory', + 'remote_connections_and_devices', +]; + +const DOMAIN_GROUPS: Partial> = { + settings: 'settings_and_credentials', + credentials: 'settings_and_credentials', + skills: 'agents_skills_and_miniapps', + miniapps: 'agents_skills_and_miniapps', + agents: 'agents_skills_and_miniapps', + workspace_sessions: 'workspaces_sessions_and_tasks', + agent_coordination: 'workspaces_sessions_and_tasks', + structured_memory: 'memory', + file_memory: 'memory', + remote_connect_devices: 'remote_connections_and_devices', + remote_ssh: 'remote_connections_and_devices', +}; + +function reportTone(status: MigrationRunStatus): StatusPillTone { + if (status === 'completed') return 'success'; + if (status === 'completed_with_warnings' || status === 'cancelled') return 'warning'; + if (status.startsWith('failed_')) return 'danger'; + return 'info'; +} + +function domainTone(state: MigrationDomainState): StatusPillTone { + if (state === 'verified') return 'success'; + if (state === 'failed') return 'danger'; + if (state === 'skipped') return 'warning'; + return 'neutral'; +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} + +export default function LegacyMigrationSettingsPage() { + const { t, formatDate, formatNumber } = useI18n('settings/legacy-migration'); + const notification = useNotification(); + const [status, setStatus] = useState(null); + const [scan, setScan] = useState(null); + const [report, setReport] = useState(null); + const [selectedGroups, setSelectedGroups] = useState>( + () => new Set(MIGRATION_GROUPS), + ); + const [loading, setLoading] = useState(true); + const [loadFailed, setLoadFailed] = useState(false); + const [busy, setBusy] = useState<'scan' | 'prepare' | 'report' | 'preference' | null>(null); + + const loadStatus = useCallback(async () => { + setLoading(true); + setLoadFailed(false); + try { + const next = await legacyMigrationAPI.getStatus(); + setStatus(next); + setReport(next.latestReport); + } catch (error) { + log.error('Failed to load legacy migration status', error); + setLoadFailed(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadStatus(); + }, [loadStatus]); + + const selection = useMemo(() => ({ + groups: MIGRATION_GROUPS.filter((group) => selectedGroups.has(group)), + }), [selectedGroups]); + + const toggleGroup = useCallback((group: MigrationGroupId) => { + setSelectedGroups((current) => { + const next = new Set(current); + if (next.has(group)) next.delete(group); + else next.add(group); + return next; + }); + }, []); + + const handleScan = useCallback(async () => { + if (selection.groups.length === 0) { + notification.warning(t('messages.emptySelection')); + return; + } + setBusy('scan'); + try { + const next = await legacyMigrationAPI.scan(selection); + setScan(next); + setStatus((current) => current ? { + ...current, + source: next.source, + onboarding: { ...current.onboarding, lastScannedAtMs: next.scannedAtMs }, + } : current); + notification.success(t('messages.scanComplete')); + } catch (error) { + log.error('Failed to scan legacy migration source', error); + notification.error(errorMessage(error, t('messages.scanFailed'))); + } finally { + setBusy(null); + } + }, [notification, selection, t]); + + const prepareMigration = useCallback(async (nextSelection: MigrationSelection) => { + if (nextSelection.groups.length === 0) { + notification.warning(t('messages.emptySelection')); + return; + } + const confirmed = await confirmDialog({ + title: t('confirm.title'), + message: t('confirm.message'), + preview: ( +
    +
  • {t('confirm.agentImpact')}
  • +
  • {t('confirm.terminalImpact')}
  • +
  • {t('confirm.fileImpact')}
  • +
  • {t('confirm.unsavedImpact')}
  • +
  • {t('confirm.closeImpact')}
  • +
+ ), + confirmText: t('actions.startMigration'), + cancelText: t('actions.cancel'), + type: 'warning', + }); + if (!confirmed) return; + + setBusy('prepare'); + try { + await legacyMigrationAPI.prepare(nextSelection); + notification.info(t('messages.handoffStarted')); + } catch (error) { + log.error('Failed to prepare legacy migration handoff', error); + notification.error(errorMessage(error, t('messages.prepareFailed'))); + setBusy(null); + } + }, [notification, t]); + + const handleLoadReport = useCallback(async () => { + setBusy('report'); + try { + const next = await legacyMigrationAPI.getReport(); + setReport(next); + if (!next) notification.info(t('messages.noReport')); + } catch (error) { + log.error('Failed to load legacy migration report', error); + notification.error(errorMessage(error, t('messages.reportFailed'))); + } finally { + setBusy(null); + } + }, [notification, t]); + + const retrySelection = useMemo(() => { + if (!report) return { groups: [] }; + const failed = new Set(); + let crossReferenceFailed = false; + for (const result of report.domainResults) { + if (result.state !== 'failed') continue; + const group = DOMAIN_GROUPS[result.domain]; + if (group) failed.add(group); + else crossReferenceFailed = true; + } + return { groups: crossReferenceFailed && failed.size === 0 ? [...MIGRATION_GROUPS] : [...failed] }; + }, [report]); + + const restoreReminder = useCallback(async () => { + setBusy('preference'); + try { + const onboarding = await legacyMigrationAPI.setPromptPreference('remind_later'); + setStatus((current) => current ? { ...current, onboarding } : current); + notification.success(t('messages.reminderRestored')); + } catch (error) { + log.error('Failed to restore legacy migration reminder', error); + notification.error(errorMessage(error, t('messages.preferenceFailed'))); + } finally { + setBusy(null); + } + }, [notification, t]); + + if (loading || loadFailed) { + return ( + + + + {loading ? ( + + ) : ( + void loadStatus()} + /> + )} + + + ); + } + + const source = status?.source ?? null; + const lastScannedAtMs = scan?.scannedAtMs ?? status?.onboarding.lastScannedAtMs ?? null; + + return ( + + + + + + + + + {source + ? t(source.supported ? 'source.supported' : 'source.unsupported') + : t('source.notDetected')} + + + + + {source + ? t('source.summary', { + product: source.productId, + version: source.productVersion || t('source.unknownVersion'), + platform: source.platform, + }) + : t('source.none')} + + + + + {lastScannedAtMs + ? formatDate(lastScannedAtMs, { dateStyle: 'medium', timeStyle: 'short' }) + : t('source.neverScanned')} + + + +
+ + + +
+
+ {status?.onboarding.choice === 'do_not_remind' ? ( + + + + ) : null} +
+ + + {MIGRATION_GROUPS.map((group) => ( + + toggleGroup(group)} + aria-label={t(`groups.${group}.label`)} + /> + + ))} + + + {scan ? ( + + {scan.findings.length === 0 ? ( + + ) : scan.findings.map((finding) => ( + + + {t(finding.migratable ? 'scan.migratable' : 'scan.blocked')} + + + ))} + + ) : null} + + + {report ? ( + <> + + {t(`statuses.${report.status}`)} + + {report.domainResults.map((result) => ( + + {t(`domainStates.${result.state}`)} + + ))} + {retrySelection.groups.length > 0 ? ( + + + + ) : null} + + ) : ( + + )} + +
+
+ ); +} diff --git a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts index 01475580a8..b51e185115 100644 --- a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts +++ b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts @@ -32,6 +32,7 @@ export const ALL_NAMESPACES = [ 'settings/editor', 'settings/external-apps', 'settings/hooks', + 'settings/legacy-migration', 'settings/mcp', 'settings/mcp-tools', 'settings/memory', diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json index 661d005665..681833b4fa 100644 --- a/src/web-ui/src/locales/en-US/settings.json +++ b/src/web-ui/src/locales/en-US/settings.json @@ -31,6 +31,7 @@ "acp": { "label": "ACP Agents", "description": "Configure external agents that connect through Agent Client Protocol." }, "usage": { "label": "Call Statistics", "description": "Model requests, token usage, and cache hit rates." }, "archivedSessions": { "label": "Archived Sessions", "description": "View, restore, or permanently delete archived sessions." }, + "legacyMigration": { "label": "Data Migration", "description": "Import supported data from an older BitFun installation." }, "diagnostics": { "label": "Logs & Diagnostics", "description": "Log levels, runtime information, and troubleshooting." } }, "views": { diff --git a/src/web-ui/src/locales/en-US/settings/legacy-migration.json b/src/web-ui/src/locales/en-US/settings/legacy-migration.json new file mode 100644 index 0000000000..c88e07a98d --- /dev/null +++ b/src/web-ui/src/locales/en-US/settings/legacy-migration.json @@ -0,0 +1,145 @@ +{ + "title": "Data Migration", + "subtitle": "Import supported data from an older BitFun installation without changing the source.", + "localOnlyNotice": "Migration is available only in OpenBitFun Desktop on the device that stores the older BitFun data. It cannot be started through Remote Control, Peer Device Mode, or Detached Dispatch.", + "sections": { + "source": { + "title": "Legacy source", + "description": "OpenBitFun performs a read-only check before the standalone Data Migrator is started." + }, + "scope": { + "title": "Migration scope", + "description": "Select high-level data groups. Session data always includes Agent coordination state." + }, + "scan": { + "title": "Latest scan", + "description": "Counts and sizes are summaries only; private content and full paths are not shown here." + }, + "report": { + "title": "Latest report", + "description": "The report shows redacted results for each owning domain. Older BitFun data is never deleted automatically." + } + }, + "fields": { + "detected": { "label": "Detection", "description": "Whether a supported local BitFun source is available." }, + "source": { "label": "Source", "description": "Product version and platform reported by the read-only probe." }, + "lastScan": { "label": "Last scanned", "description": "Time of the latest explicit read-only scan." }, + "actions": { "label": "Actions", "description": "Scan locally, start the standalone migrator, or refresh the report." }, + "reminder": { "label": "First-start reminder", "description": "Automatic migration prompts are currently disabled for this source." }, + "retryFailed": { "label": "Retry failed groups", "description": "Start a new standalone run containing only groups with failed domains." } + }, + "source": { + "supported": "Supported", + "unsupported": "Unsupported format", + "notDetected": "Not detected", + "summary": "{{product}} {{version}} on {{platform}}", + "unknownVersion": "unknown version", + "none": "No older BitFun data was detected on this device.", + "neverScanned": "Never scanned" + }, + "actions": { + "scan": "Scan old data", + "startMigration": "Start migration", + "viewReport": "View report", + "retryFailed": "Retry failed items", + "restoreReminder": "Restore reminder", + "retry": "Retry", + "cancel": "Cancel" + }, + "groups": { + "settings_and_credentials": { "label": "Settings and credentials", "description": "Supported preferences, model/service configuration, and secure credential references." }, + "agents_skills_and_miniapps": { "label": "Agents, Skills, and MiniApps", "description": "User Agents, non-system Skills, and non-built-in MiniApps." }, + "workspaces_sessions_and_tasks": { "label": "Workspaces, sessions, and Agent tasks", "description": "Workspace and session records together with required coordination state." }, + "memory": { "label": "Memory", "description": "Supported structured memory and user-authored memory files." }, + "remote_connections_and_devices": { "label": "Remote connections and devices", "description": "Supported Remote Connect device records and SSH profiles; secrets may require reauthentication." } + }, + "scan": { + "empty": "The selected groups contain no migratable records.", + "summary": "{{count}} records, {{bytes}} logical bytes", + "migratable": "Migratable", + "blocked": "Needs attention" + }, + "report": { + "result": "Run result", + "none": "No migration report is available yet.", + "timeUnavailable": "Completion time unavailable", + "domainCounts": "{{imported}} imported, {{skipped}} skipped, {{conflicts}} conflicts" + }, + "statuses": { + "discovered": "Discovered", + "scanned": "Scanned", + "planned": "Planned", + "waiting_for_processes": "Waiting for applications", + "staging": "Staging", + "validating_stage": "Validating staged data", + "committing": "Committing", + "validating_commit": "Validating imported data", + "completed": "$t(shared:statuses.done)", + "completed_with_warnings": "Completed with warnings", + "cancelled": "$t(shared:statuses.cancelled)", + "failed_recoverable": "Failed; retry available", + "failed_manual_action_required": "Manual action required" + }, + "domainStates": { + "not_started": "Not started", + "staged": "Staged", + "committed": "Committed", + "verified": "Verified", + "failed": "$t(shared:statuses.failed)", + "skipped": "Skipped" + }, + "domains": { + "settings": "$t(shared:features.settings)", + "credentials": "Credentials", + "skills": "Skills", + "miniapps": "MiniApps", + "agents": "Agents", + "workspace_sessions": "Workspaces and sessions", + "agent_coordination": "Agent coordination", + "structured_memory": "Structured memory", + "file_memory": "Memory files", + "remote_connect_devices": "Remote Connect devices", + "remote_ssh": "Remote SSH", + "cross_reference_repair": "Cross-reference repair" + }, + "messages": { + "loading": "Loading migration status…", + "loadFailed": "Migration status could not be loaded on this device.", + "emptySelection": "Select at least one migration group.", + "scanComplete": "Read-only scan completed.", + "scanFailed": "The old data could not be scanned safely.", + "handoffStarted": "The Data Migrator is starting. OpenBitFun will close after the handoff.", + "prepareFailed": "The Data Migrator could not be started safely.", + "noReport": "No migration report is available yet.", + "reportFailed": "The migration report could not be loaded.", + "reminderRestored": "The first-start migration reminder was restored.", + "preferenceFailed": "The reminder preference could not be changed." + }, + "startupNotification": { + "title": "BitFun data migration", + "statuses": { + "discovered": "The Data Migrator discovered old data but did not finish a migration.", + "scanned": "The Data Migrator completed a read-only scan.", + "planned": "The Data Migrator saved a plan but did not import data.", + "waiting_for_processes": "Migration is still waiting for local applications to close.", + "staging": "Migration stopped while staging data and can be resumed safely.", + "validating_stage": "Migration stopped while validating staged data and can be resumed safely.", + "committing": "Migration stopped during a domain commit; review the report before retrying.", + "validating_commit": "Migration stopped while validating imported data; review the report.", + "completed": "Supported BitFun data was migrated successfully.", + "completed_with_warnings": "BitFun data migration completed with warnings.", + "cancelled": "BitFun data migration was cancelled at a safe boundary.", + "failed_recoverable": "BitFun data migration did not finish and can be retried.", + "failed_manual_action_required": "BitFun data migration needs manual action before it can continue." + } + }, + "confirm": { + "title": "Close OpenBitFun and start Data Migrator?", + "message": "The standalone Data Migrator needs exclusive access to local product data. Review the shutdown impact before continuing.", + "agentImpact": "Running Agent turns will stop.", + "terminalImpact": "Terminal tasks and other child processes may stop.", + "fileImpact": "Active file writes must finish before migration can proceed.", + "unsavedImpact": "Unsaved editor or settings changes may be lost.", + "closeImpact": "OpenBitFun will close, the migrator will confirm the final scope, and OpenBitFun will reopen when migration ends." + } +} diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json index 6af3eb7f42..eb67be05bd 100644 --- a/src/web-ui/src/locales/zh-CN/settings.json +++ b/src/web-ui/src/locales/zh-CN/settings.json @@ -31,6 +31,7 @@ "acp": { "label": "ACP Agent", "description": "配置通过 Agent Client Protocol 接入的外部 Agent。" }, "usage": { "label": "调用统计", "description": "模型请求数、Token 用量与缓存命中率。" }, "archivedSessions": { "label": "已归档会话", "description": "查看、恢复或永久删除已归档的会话。" }, + "legacyMigration": { "label": "数据迁移", "description": "从受支持的旧版 BitFun 安装导入数据。" }, "diagnostics": { "label": "日志与诊断", "description": "日志级别、运行信息与故障排查。" } }, "views": { diff --git a/src/web-ui/src/locales/zh-CN/settings/legacy-migration.json b/src/web-ui/src/locales/zh-CN/settings/legacy-migration.json new file mode 100644 index 0000000000..773c777fff --- /dev/null +++ b/src/web-ui/src/locales/zh-CN/settings/legacy-migration.json @@ -0,0 +1,133 @@ +{ + "title": "数据迁移", + "subtitle": "从旧版 BitFun 安装导入受支持的数据,同时保持来源不变。", + "localOnlyNotice": "只能在保存旧 BitFun 数据的设备上,通过 OpenBitFun Desktop 启动迁移。Remote Control、Peer Device Mode 和 Detached Dispatch 均不能启动迁移。", + "sections": { + "source": { "title": "旧数据来源", "description": "启动独立 Data Migrator 前,OpenBitFun 会先执行只读检查。" }, + "scope": { "title": "迁移范围", "description": "选择高层数据组。会话数据始终包含 Agent 协调状态。" }, + "scan": { "title": "最近扫描", "description": "这里只显示数量和大小摘要,不显示私密正文或完整路径。" }, + "report": { "title": "最近报告", "description": "报告按所属领域显示去敏结果。旧 BitFun 数据永远不会被自动删除。" } + }, + "fields": { + "detected": { "label": "检测状态", "description": "本机是否存在受支持的 BitFun 数据来源。" }, + "source": { "label": "来源", "description": "只读探测得到的产品版本和平台。" }, + "lastScan": { "label": "上次扫描", "description": "最近一次显式只读扫描的时间。" }, + "actions": { "label": "操作", "description": "在本机扫描、启动独立迁移器或刷新报告。" }, + "reminder": { "label": "首次启动提醒", "description": "当前来源的自动迁移提示已关闭。" }, + "retryFailed": { "label": "重试失败数据组", "description": "仅使用包含失败领域的数据组启动新的独立迁移。" } + }, + "source": { + "supported": "支持迁移", + "unsupported": "格式不受支持", + "notDetected": "未检测到", + "summary": "{{platform}} 上的 {{product}} {{version}}", + "unknownVersion": "未知版本", + "none": "此设备上未检测到旧版 BitFun 数据。", + "neverScanned": "尚未扫描" + }, + "actions": { + "scan": "扫描旧数据", + "startMigration": "开始迁移", + "viewReport": "查看报告", + "retryFailed": "重试失败项", + "restoreReminder": "恢复提醒", + "retry": "重试", + "cancel": "取消" + }, + "groups": { + "settings_and_credentials": { "label": "设置和凭据", "description": "受支持的偏好、模型与服务配置,以及安全凭据引用。" }, + "agents_skills_and_miniapps": { "label": "Agents、Skills 和 MiniApps", "description": "用户 Agents、非系统 Skills 和非内置 MiniApps。" }, + "workspaces_sessions_and_tasks": { "label": "工作区、会话与 Agent 任务", "description": "工作区和会话记录,以及必需的协调状态。" }, + "memory": { "label": "Memory", "description": "受支持的结构化记忆和用户编写的记忆文件。" }, + "remote_connections_and_devices": { "label": "远程连接和设备", "description": "受支持的 Remote Connect 设备记录和 SSH 配置;秘密可能需要重新认证。" } + }, + "scan": { + "empty": "所选数据组中没有可迁移记录。", + "summary": "{{count}} 条记录,{{bytes}} 逻辑字节", + "migratable": "可迁移", + "blocked": "需要处理" + }, + "report": { + "result": "运行结果", + "none": "尚无迁移报告。", + "timeUnavailable": "完成时间不可用", + "domainCounts": "导入 {{imported}},跳过 {{skipped}},冲突 {{conflicts}}" + }, + "statuses": { + "discovered": "已发现", + "scanned": "已扫描", + "planned": "已计划", + "waiting_for_processes": "正在等待应用退出", + "staging": "正在暂存", + "validating_stage": "正在验证暂存数据", + "committing": "正在提交", + "validating_commit": "正在验证导入数据", + "completed": "$t(shared:statuses.done)", + "completed_with_warnings": "已完成,但有警告", + "cancelled": "$t(shared:statuses.cancelled)", + "failed_recoverable": "失败,可以重试", + "failed_manual_action_required": "需要手动处理" + }, + "domainStates": { + "not_started": "未开始", + "staged": "已暂存", + "committed": "已提交", + "verified": "已验证", + "failed": "$t(shared:statuses.failed)", + "skipped": "已跳过" + }, + "domains": { + "settings": "$t(shared:features.settings)", + "credentials": "凭据", + "skills": "Skills", + "miniapps": "MiniApps", + "agents": "Agents", + "workspace_sessions": "工作区和会话", + "agent_coordination": "Agent 协调", + "structured_memory": "结构化记忆", + "file_memory": "记忆文件", + "remote_connect_devices": "Remote Connect 设备", + "remote_ssh": "Remote SSH", + "cross_reference_repair": "交叉引用修复" + }, + "messages": { + "loading": "正在加载迁移状态…", + "loadFailed": "无法加载此设备上的迁移状态。", + "emptySelection": "请至少选择一个迁移数据组。", + "scanComplete": "只读扫描已完成。", + "scanFailed": "无法安全扫描旧数据。", + "handoffStarted": "Data Migrator 正在启动。交接完成后 OpenBitFun 将关闭。", + "prepareFailed": "无法安全启动 Data Migrator。", + "noReport": "尚无迁移报告。", + "reportFailed": "无法加载迁移报告。", + "reminderRestored": "已恢复首次启动迁移提醒。", + "preferenceFailed": "无法修改提醒偏好。" + }, + "startupNotification": { + "title": "BitFun 数据迁移", + "statuses": { + "discovered": "Data Migrator 发现了旧数据,但尚未完成迁移。", + "scanned": "Data Migrator 已完成只读扫描。", + "planned": "Data Migrator 已保存计划,但尚未导入数据。", + "waiting_for_processes": "迁移仍在等待本机应用退出。", + "staging": "迁移在暂存数据时停止,可以安全恢复。", + "validating_stage": "迁移在验证暂存数据时停止,可以安全恢复。", + "committing": "迁移在提交领域数据时停止;重试前请查看报告。", + "validating_commit": "迁移在验证导入数据时停止;请查看报告。", + "completed": "受支持的 BitFun 数据已成功迁移。", + "completed_with_warnings": "BitFun 数据迁移已完成,但有警告。", + "cancelled": "BitFun 数据迁移已在安全边界取消。", + "failed_recoverable": "BitFun 数据迁移未完成,可以重试。", + "failed_manual_action_required": "BitFun 数据迁移需要手动处理后才能继续。" + } + }, + "confirm": { + "title": "关闭 OpenBitFun 并启动 Data Migrator?", + "message": "独立 Data Migrator 需要独占访问本机产品数据。继续前请确认关闭影响。", + "agentImpact": "正在运行的 Agent 回合将停止。", + "terminalImpact": "终端任务和其他子进程可能会停止。", + "fileImpact": "迁移开始前,正在进行的文件写入必须完成。", + "unsavedImpact": "未保存的编辑器或设置更改可能会丢失。", + "closeImpact": "OpenBitFun 将关闭;迁移器会再次确认最终范围,并在迁移结束后重新打开 OpenBitFun。" + } +} diff --git a/src/web-ui/src/locales/zh-TW/settings.json b/src/web-ui/src/locales/zh-TW/settings.json index 17f45bf10f..63fd98ab61 100644 --- a/src/web-ui/src/locales/zh-TW/settings.json +++ b/src/web-ui/src/locales/zh-TW/settings.json @@ -31,6 +31,7 @@ "acp": { "label": "ACP Agent", "description": "設定透過 Agent Client Protocol 接入的外部 Agent。" }, "usage": { "label": "調用統計", "description": "模型請求數、Token 用量與快取命中率。" }, "archivedSessions": { "label": "已歸檔會話", "description": "檢視、恢復或永久刪除已歸檔的會話。" }, + "legacyMigration": { "label": "資料遷移", "description": "從受支援的舊版 BitFun 安裝匯入資料。" }, "diagnostics": { "label": "日誌與診斷", "description": "日誌層級、執行資訊與故障排查。" } }, "views": { diff --git a/src/web-ui/src/locales/zh-TW/settings/legacy-migration.json b/src/web-ui/src/locales/zh-TW/settings/legacy-migration.json new file mode 100644 index 0000000000..b965724f89 --- /dev/null +++ b/src/web-ui/src/locales/zh-TW/settings/legacy-migration.json @@ -0,0 +1,133 @@ +{ + "title": "資料遷移", + "subtitle": "從舊版 BitFun 安裝匯入受支援的資料,同時保持來源不變。", + "localOnlyNotice": "只能在儲存舊 BitFun 資料的裝置上,透過 OpenBitFun Desktop 啟動遷移。Remote Control、Peer Device Mode 和 Detached Dispatch 均不能啟動遷移。", + "sections": { + "source": { "title": "舊資料來源", "description": "啟動獨立 Data Migrator 前,OpenBitFun 會先執行唯讀檢查。" }, + "scope": { "title": "遷移範圍", "description": "選擇高層資料群組。會話資料始終包含 Agent 協調狀態。" }, + "scan": { "title": "最近掃描", "description": "這裡只顯示數量和大小摘要,不顯示私密正文或完整路徑。" }, + "report": { "title": "最近報告", "description": "報告按所屬領域顯示去敏結果。舊 BitFun 資料永遠不會被自動刪除。" } + }, + "fields": { + "detected": { "label": "偵測狀態", "description": "本機是否存在受支援的 BitFun 資料來源。" }, + "source": { "label": "來源", "description": "唯讀探測得到的產品版本和平台。" }, + "lastScan": { "label": "上次掃描", "description": "最近一次明確唯讀掃描的時間。" }, + "actions": { "label": "操作", "description": "在本機掃描、啟動獨立遷移器或重新整理報告。" }, + "reminder": { "label": "首次啟動提醒", "description": "目前來源的自動遷移提示已關閉。" }, + "retryFailed": { "label": "重試失敗資料群組", "description": "僅使用包含失敗領域的資料群組啟動新的獨立遷移。" } + }, + "source": { + "supported": "支援遷移", + "unsupported": "格式不受支援", + "notDetected": "未偵測到", + "summary": "{{platform}} 上的 {{product}} {{version}}", + "unknownVersion": "未知版本", + "none": "此裝置上未偵測到舊版 BitFun 資料。", + "neverScanned": "尚未掃描" + }, + "actions": { + "scan": "掃描舊資料", + "startMigration": "開始遷移", + "viewReport": "檢視報告", + "retryFailed": "重試失敗項", + "restoreReminder": "恢復提醒", + "retry": "重試", + "cancel": "取消" + }, + "groups": { + "settings_and_credentials": { "label": "設定和憑據", "description": "受支援的偏好、模型與服務設定,以及安全憑據引用。" }, + "agents_skills_and_miniapps": { "label": "Agents、Skills 和 MiniApps", "description": "使用者 Agents、非系統 Skills 和非內建 MiniApps。" }, + "workspaces_sessions_and_tasks": { "label": "工作區、會話與 Agent 任務", "description": "工作區和會話記錄,以及必需的協調狀態。" }, + "memory": { "label": "Memory", "description": "受支援的結構化記憶和使用者編寫的記憶檔案。" }, + "remote_connections_and_devices": { "label": "遠端連線和裝置", "description": "受支援的 Remote Connect 裝置記錄和 SSH 設定;秘密可能需要重新驗證。" } + }, + "scan": { + "empty": "所選資料群組中沒有可遷移記錄。", + "summary": "{{count}} 筆記錄,{{bytes}} 邏輯位元組", + "migratable": "可遷移", + "blocked": "需要處理" + }, + "report": { + "result": "執行結果", + "none": "尚無遷移報告。", + "timeUnavailable": "完成時間不可用", + "domainCounts": "匯入 {{imported}},跳過 {{skipped}},衝突 {{conflicts}}" + }, + "statuses": { + "discovered": "已發現", + "scanned": "已掃描", + "planned": "已規劃", + "waiting_for_processes": "正在等待應用程式退出", + "staging": "正在暫存", + "validating_stage": "正在驗證暫存資料", + "committing": "正在提交", + "validating_commit": "正在驗證匯入資料", + "completed": "$t(shared:statuses.done)", + "completed_with_warnings": "已完成,但有警告", + "cancelled": "$t(shared:statuses.cancelled)", + "failed_recoverable": "失敗,可以重試", + "failed_manual_action_required": "需要手動處理" + }, + "domainStates": { + "not_started": "未開始", + "staged": "已暫存", + "committed": "已提交", + "verified": "已驗證", + "failed": "$t(shared:statuses.failed)", + "skipped": "已跳過" + }, + "domains": { + "settings": "$t(shared:features.settings)", + "credentials": "憑據", + "skills": "Skills", + "miniapps": "MiniApps", + "agents": "Agents", + "workspace_sessions": "工作區和會話", + "agent_coordination": "Agent 協調", + "structured_memory": "結構化記憶", + "file_memory": "記憶檔案", + "remote_connect_devices": "Remote Connect 裝置", + "remote_ssh": "Remote SSH", + "cross_reference_repair": "交叉引用修復" + }, + "messages": { + "loading": "正在載入遷移狀態…", + "loadFailed": "無法載入此裝置上的遷移狀態。", + "emptySelection": "請至少選擇一個遷移資料群組。", + "scanComplete": "唯讀掃描已完成。", + "scanFailed": "無法安全掃描舊資料。", + "handoffStarted": "Data Migrator 正在啟動。交接完成後 OpenBitFun 將關閉。", + "prepareFailed": "無法安全啟動 Data Migrator。", + "noReport": "尚無遷移報告。", + "reportFailed": "無法載入遷移報告。", + "reminderRestored": "已恢復首次啟動遷移提醒。", + "preferenceFailed": "無法修改提醒偏好。" + }, + "startupNotification": { + "title": "BitFun 資料遷移", + "statuses": { + "discovered": "Data Migrator 發現了舊資料,但尚未完成遷移。", + "scanned": "Data Migrator 已完成唯讀掃描。", + "planned": "Data Migrator 已儲存計畫,但尚未匯入資料。", + "waiting_for_processes": "遷移仍在等待本機應用程式退出。", + "staging": "遷移在暫存資料時停止,可以安全恢復。", + "validating_stage": "遷移在驗證暫存資料時停止,可以安全恢復。", + "committing": "遷移在提交領域資料時停止;重試前請檢視報告。", + "validating_commit": "遷移在驗證匯入資料時停止;請檢視報告。", + "completed": "受支援的 BitFun 資料已成功遷移。", + "completed_with_warnings": "BitFun 資料遷移已完成,但有警告。", + "cancelled": "BitFun 資料遷移已在安全邊界取消。", + "failed_recoverable": "BitFun 資料遷移未完成,可以重試。", + "failed_manual_action_required": "BitFun 資料遷移需要手動處理後才能繼續。" + } + }, + "confirm": { + "title": "關閉 OpenBitFun 並啟動 Data Migrator?", + "message": "獨立 Data Migrator 需要獨占存取本機產品資料。繼續前請確認關閉影響。", + "agentImpact": "正在執行的 Agent 回合將停止。", + "terminalImpact": "終端任務和其他子程序可能會停止。", + "fileImpact": "遷移開始前,正在進行的檔案寫入必須完成。", + "unsavedImpact": "未儲存的編輯器或設定變更可能會遺失。", + "closeImpact": "OpenBitFun 將關閉;遷移器會再次確認最終範圍,並在遷移結束後重新開啟 OpenBitFun。" + } +} From a22e57b5be08c3a1c59e42a442858381927e5def Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 7 Sep 2026 21:46:37 +0800 Subject: [PATCH 5/7] fix(migration): Refine imports and startup Migration testing exposed lost configuration fields, stale assistant workspace context, and launch failures that prevented normal imports. Preserve core user data and correct the startup and reporting paths. - Preserve agent profiles, default models, and unknown extension content instead of discarding data through narrow conversions. - Import assistant workspaces and rehome session workspace metadata while leaving historical tool calls and turn content unchanged. - Omit rebuildable prompt caches and non-core data from migration. - Keep orphaned session relationships from blocking an entire import and accept empty legacy SSH workspace records. - Support long Windows storage paths and retain sanitized failure details without exposing user content or credentials. - Add diagnostic export and recovery coverage, and distinguish staged counts from verified results in the migration report. - Gate onboarding actions until bootstrap is ready and correct completion behavior for development and release handoffs. - Allow the existing opt-in development retry for Windows Job breakaway failures and report launch errors without blocking Desktop. - Remove the Authenticode prerequisite across launch and restart paths while retaining fixed-name sibling and file-layout checks. - Restrict import helper visibility and keep memory test helpers out of production builds. Align the migration inventory and implementation notes with the reduced scope and the behavior established during migration testing. --- ...GACY_DATA_MIGRATION_IMPLEMENTATION_PLAN.md | 907 ------------------ OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md | 569 ----------- .../src-tauri/src/installer/commands.rs | 5 +- scripts/data-migrator-tauri-build.test.mjs | 21 + src/apps/data-migrator/AGENTS.md | 4 +- src/apps/data-migrator/src/app_state.rs | 127 ++- src/apps/data-migrator/src/commands.rs | 11 +- src/apps/data-migrator/src/lib.rs | 1 + src/apps/data-migrator/ui/app.js | 69 +- src/apps/data-migrator/ui/index.html | 7 +- .../desktop/src/api/legacy_migration_api.rs | 89 +- src/apps/desktop/src/lib.rs | 7 +- .../assembly/core/src/agentic/memories/db.rs | 4 +- .../infrastructure/app_paths/path_manager.rs | 2 +- .../legacy_migration/agent_coordination.rs | 65 ++ .../core/src/legacy_migration/extensions.rs | 670 +++++++------ .../core/src/legacy_migration/settings.rs | 754 +++++++++++---- .../legacy_migration/workspace_sessions.rs | 652 +++++++++++-- .../core/src/service/workspace/types.rs | 2 +- .../src/legacy_migration/mod.rs | 52 + .../services/legacy-migration/Cargo.toml | 1 - .../legacy-migration/src/diagnostics.rs | 253 +++++ .../services/legacy-migration/src/engine.rs | 173 +++- .../services/legacy-migration/src/handoff.rs | 171 ++-- .../services/legacy-migration/src/lib.rs | 6 +- .../services/legacy-migration/src/storage.rs | 78 +- .../tests/fixtures/support-matrix.json | 1 + .../tests/fixtures/v0.2.19/README.md | 7 + .../tests/migration_engine_contracts.rs | 371 ++++++- .../services-core/src/process_manager.rs | 41 +- .../src/remote_persistence.rs | 38 +- ...legacyMigrationStartupNotification.test.ts | 35 +- .../legacyMigrationStartupNotification.ts | 43 +- .../api/service-api/LegacyMigrationAPI.ts | 7 + .../en-US/settings/legacy-migration.json | 2 + .../zh-CN/settings/legacy-migration.json | 2 + .../zh-TW/settings/legacy-migration.json | 2 + 37 files changed, 3071 insertions(+), 2178 deletions(-) delete mode 100644 OPENBITFUN_LEGACY_DATA_MIGRATION_IMPLEMENTATION_PLAN.md delete mode 100644 OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md create mode 100644 src/crates/services/legacy-migration/src/diagnostics.rs diff --git a/OPENBITFUN_LEGACY_DATA_MIGRATION_IMPLEMENTATION_PLAN.md b/OPENBITFUN_LEGACY_DATA_MIGRATION_IMPLEMENTATION_PLAN.md deleted file mode 100644 index 88b8c77f83..0000000000 --- a/OPENBITFUN_LEGACY_DATA_MIGRATION_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,907 +0,0 @@ -# OpenBitFun 旧版 BitFun 数据迁移实现方案 - -> 状态:V1 实现设计 -> 日期:2026-09-05 -> 上游盘点:[OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md](OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md) - -## 1. 结论 - -BitFun 到 OpenBitFun 的迁移不应实现为“把旧用户目录递归复制到新目录”,而应实现为一个**版本化、按数据领域解析、可恢复的离线导入系统**。 - -V1 采用以下产品与技术形态: - -- 提供一个与 OpenBitFun 一同安装、独立签名和打包的 **OpenBitFun Data Migrator**。 -- 主应用第一次启动时,在日志、全局配置、会话数据库等常规存储打开之前,只做轻量的旧数据发现和迁移状态判断。 -- 检测到旧 BitFun 数据且尚未作出选择时,主应用以 onboarding 模式启动 Data Migrator,然后在打开常规产品存储前退出;“立即迁移”“稍后提醒”“不再提醒”由迁移器提供。 -- 用户选择立即迁移后,Data Migrator 留在当前进程中继续预检和迁移;选择稍后或不再提醒时,迁移器记录决定并重新启动主应用。 -- Data Migrator 等待主应用及相关写入进程完全退出,按用户选择扫描、预演、暂存、校验和提交,最后按结果重新启动 OpenBitFun。 -- 设置页在“数据与维护”下永久保留“数据迁移”入口,支持首次启动后补迁、查看历史报告和重试失败领域。 -- 旧 BitFun 数据始终只读;迁移成功后也不自动删除。 -- 迁移以领域为原子提交单位,并通过全局迁移日志实现断点续传。不能宣称多个独立数据库和文件目录之间存在真实的全局事务。 -- 目标端已有数据默认优先;冲突必须可见,不静默覆盖。 -- 所有迁移操作只在存放旧数据的本机执行。远程控制、Peer Device 和 Detached Dispatch 不得把迁移请求静默转发到另一台机器或回退到控制端本地数据。 - -这比把迁移逻辑放进安装器或主应用进程更稳妥:安装器只负责安装和可选启动,主应用不在自身持有数据库、日志、WebView 配置和运行时锁时改写这些数据,迁移器也可以独立恢复、升级和记录结果。 - -## 2. 目标与非目标 - -### 2.1 V1 目标 - -V1 只保证高价值用户数据的连续性: - -1. 设置与服务凭据。 -2. Agents、用户 Skills 与用户 MiniApps。 -3. 工作区、会话与 Agent 任务状态。 -4. 记忆。 -5. 远程连接与设备。 - -系统还必须满足: - -- 能判断是否存在可迁移的旧 BitFun 安装。 -- 能给出迁移前扫描结果、预计范围、依赖项、冲突和不可迁移项。 -- 能安全处理中途退出、进程崩溃、磁盘不足和局部领域失败。 -- 同一来源可重复执行,重复执行不产生重复数据。 -- 能从旧数据版本升级到当前 OpenBitFun 数据模型,而不要求旧版应用先启动升级。 -- 能清楚说明“已迁移”“已跳过”“有冲突”“需要重新登录/重新配对”“失败且可重试”。 - -### 2.2 V1 非目标 - -V1 不做以下事情: - -- 不逐字节克隆整个 `%APPDATA%\bitfun` 目录。 -- 不迁移缓存、日志、临时文件、PID、锁、Socket、IPC 发现文件或 WebView 缓存。 -- 不迁移旧版内置资源;OpenBitFun 应从当前安装包重新生成内置 Agents、系统 Skills 和内置 MiniApps。 -- 不复制工作区中的真实项目文件;只迁移工作区登记、连接配置和会话引用。 -- 不在迁移完成后删除、移动或改名旧数据。 -- 不提供 BitFun 与 OpenBitFun 间的持续双向同步。 -- 不在 V1 支持逐个选择 Session。会话树与全局 Agent Runtime 协调数据库存在跨会话引用,拆分迁移会制造不可证明的悬空关系。 -- 不保证无法解密、与旧机器硬件绑定或协议已废弃的密钥继续可用;此类项目必须保留非敏感元数据并明确要求用户重新认证。 -- 不把未经识别的旧文件“先复制过去再说”。 - -## 3. 核心原则 - -### 3.1 按所有者迁移,不按目录迁移 - -每类数据由其当前产品领域所有者定义: - -- 如何识别旧版本; -- 哪些文件和表属于该领域; -- 如何解析和校验; -- 如何转换为当前模型; -- 如何检测重复和冲突; -- 如何提交及验证; -- 哪些数据必须作为一个原子子域处理。 - -通用迁移引擎只负责编排、锁、暂存、日志、恢复和报告,不猜测领域数据语义。 - -### 3.2 离线写入 - -只要迁移范围包含主应用常规会打开的数据,迁移必须在 OpenBitFun 主应用退出后执行。Data Migrator 需要取得目标迁移锁,并确认没有 OpenBitFun、BitFun 或 Agent Runtime 进程仍在写入选中的源和目标存储。 - -默认只请求优雅退出,不主动强杀进程。若仍有进程占用,迁移器应展示具体进程和“重试 / 取消”操作;强制结束进程只能是明确、二次确认的用户操作。 - -### 3.3 源只读,目标可恢复 - -- 不修改旧数据库,不在旧目录创建 marker,不对旧 SQLite 执行写入式 checkpoint。 -- 读取 SQLite 时使用 SQLite 备份/一致性快照能力,把主数据库及 WAL 中的有效内容合并到暂存数据库;不能只复制 `.sqlite` 文件。 -- 在提交前对目标端将被修改的数据创建可恢复备份或回滚记录。 -- 暂存目录与目标数据放在同一卷,确保文件替换可以使用原子 rename。 -- 每个领域提交后立即做读取验证,再写入提交标记。 - -### 3.4 可重复、可恢复、可解释 - -一次迁移由稳定的来源指纹、所选范围、转换器版本和计划哈希标识。每个源实体应有来源 ID/哈希到目标 ID 的映射。同一计划重试时: - -- 已成功且校验通过的领域直接跳过; -- 暂存未提交的领域可以重新生成; -- 提交状态不确定的领域先按结果校验,不盲目再写一次; -- 失败不抹掉已成功领域; -- 最终报告与界面状态一致。 - -## 4. 数据根目录与发现 - -### 4.1 Windows 示例 - -旧版 Windows 数据根目录的典型位置是: - -`%APPDATA%\bitfun` - -例如当前用户可能是: - -`C:\Users\wsp\AppData\Roaming\bitfun` - -实现中不得硬编码用户名或这个绝对路径。源路径应由平台目录解析器和旧产品标识共同解析;目标路径必须通过 OpenBitFun 的 AppPaths/PathManager 获取,不能在各迁移适配器内自行拼接。 - -还需要按领域发现用户主目录下的数据,例如: - -`%USERPROFILE%\.bitfun\memories` - -### 4.2 来源发现条件 - -轻量检测器只读取少量稳定标记,输出: - -- 是否存在旧 BitFun 根目录; -- 是否存在至少一个受支持领域的数据; -- 可识别的旧产品版本/模式版本; -- 路径可读性; -- 粗略数据量; -- 是否疑似仍有 BitFun 进程运行; -- 是否已经对同一来源完成过迁移。 - -目录存在本身不等于可迁移。空目录、只有缓存/日志的目录以及不受支持的未来格式不能触发“可迁移”结论。 - -如果发现多个来源目录,首次引导默认选择当前用户最可信的来源,并允许用户在迁移器内查看和改选;不得自动合并多个来源。 - -### 4.3 “第一次打开”的定义 - -“第一次打开”由 OpenBitFun 自己的数据目录中的 onboarding 状态判断,而不是通过“目标目录是否为空”判断。至少记录: - -- 来源指纹; -- 检测时间; -- 用户选择:立即、稍后、不再提醒; -- 最近一次提示版本; -- 对应迁移运行 ID。 - -“不再提醒”只关闭自动引导,不移除设置页常驻入口,也不把旧数据标记为已迁移。 - -## 5. V1 迁移分组 - -用户界面只展示五个高层组;组内的依赖和原子子域由系统管理,不能提供会破坏一致性的任意文件勾选。 - -### 5.1 设置与服务凭据 - -包含: - -- 用户设置、功能开关、主题/外观、语言、模型与服务配置; -- 可移植的提供商账户配置和服务端点; -- 由凭据引用关联的 API Key、Token 或密码; -- 与当前版本仍有明确语义映射的实验选项。 - -规则: - -- 只迁移当前设置所有者列入映射表的 key,并通过版本化转换器写入当前 schema。 -- 未知或已废弃 key 记录为“未支持”,不能原样注入当前配置。 -- 目标端已显式设置的值优先;只有目标端仍是缺省值或不存在时,才接受源值。 -- 凭据不通过日志、命令行参数、迁移报告或普通 JSON 暂存。 -- 凭据应通过旧凭据所有者/安全存储读取,并写入当前 Vault/系统凭据库。无法解密时迁移非敏感配置并标记“需要重新登录或重新填写凭据”。 -- 机器绑定或用户 SID 绑定的秘密不能通过简单文件复制绕过安全边界。 - -### 5.2 Agents、Skills 与 MiniApps - -#### Agents - -来源: - -`%APPDATA%\bitfun\agents` - -迁移所有正式的用户 Agent 定义及其由 Agent 所有者声明的附件。当前安装包提供的内置 Agent 不从旧目录覆盖。 - -处理规则: - -- 先解析 manifest/schema,再复制允许的资源; -- 保留稳定来源 ID,并在冲突时建立来源 ID 到新目标 ID 的映射; -- 引用 Skill、MiniApp 或模型配置的字段必须在相关领域映射完成后重写; -- 无法解析的 Agent 保留在隔离报告中,不注册到产品运行时。 - -#### Skills - -来源: - -`%APPDATA%\bitfun\skills` - -包含: - -- `skills` 下除根级 `.system` 目录外的用户 Skill; -- Skill manifest 声明的脚本、模板和资源。 - -排除: - -`%APPDATA%\bitfun\skills\.system` - -`.system` 由 OpenBitFun 当前版本重新生成。实现应以 `OPENBITFUN_SYSTEM_SKILL_DIR` 所代表的稳定产品事实识别系统目录,不在多个位置重复硬编码字符串。 - -迁移期间不执行 Skill 脚本、不加载插件、不运行 hook。路径校验必须拒绝越界路径、junction/reparse point 和循环链接。 - -#### MiniApps - -来源: - -`%APPDATA%\bitfun\data\miniapps` - -包含: - -- 用户安装或创建的非内置 MiniApp; -- MiniApp 所有者明确声明可迁移的用户存储和自定义数据。 - -排除: - -- 当前或旧版内置 MiniApp 的安装源; -- 可由安装包重新生成的 bundle; -- 下载缓存、构建缓存和临时运行状态。 - -内置身份应结合内置 ID 清单、`BUILTIN_APPS` 与 `.builtin-manifest.json` 判断,不能只根据目录名猜测。若内置 MiniApp 曾产生用户数据,应由 MiniApp 数据适配器把“内置代码”和“用户数据”分开:代码不迁移,兼容的用户数据可以迁移。 - -### 5.3 工作区、会话与 Agent 任务状态 - -这是一个用户可选组,但在内部至少包含“工作区/会话存储”和“Agent Runtime 协调数据库”两个有依赖的原子子域。 - -包含: - -- 工作区注册信息、最近使用记录和会话关联; -- 完整 Session 树、消息、附件引用、运行记录及所有者声明的索引; -- `runtime-events` 等恢复旧会话所必需的持久事件; -- `%APPDATA%\bitfun\data\agent-runtime\coordination.sqlite`。 - -`coordination.sqlite` 是会话数据的一部分,而不是可选缓存。旧 Session 中的 Subagent、任务关系、投递/协调状态依赖它。V1 中只要用户选择“工作区、会话与 Agent 任务状态”,迁移计划就必须自动包含该数据库,界面可以解释依赖但不能取消勾选。 - -排除 Agent Runtime 的短期进程状态: - -- `ownership`; -- `ipc-v*`; -- PID、lock、socket、端口发现文件; -- 运行实例租约、心跳和可重新生成的 discovery 状态; -- SQLite `-shm` 文件; -- 其他只在旧进程存活期间有效的数据。 - -SQLite `-wal` 不是独立目标文件,但如果存在,必须作为一致性读取输入,由 SQLite 备份流程合并进暂存数据库。源端保持只读,最终目标数据库不携带旧 `-wal`/`-shm`。 - -会话迁移还必须满足: - -- V1 不允许逐个选择 Session。 -- 先建立工作区、Session、Turn、Subagent/Task 等实体的完整 ID 映射,再写交叉引用。 -- 无法解析的新旧状态不能通过删除记录“修复”;应保留源记录、降级该会话并给出明确状态。 -- 工作区本地路径只作为引用迁移,不复制目录内容。路径不存在时仍保留记录并标记“需要重新定位”。 -- 远程工作区路径按远端 POSIX 路径处理,不能用 Windows `std::path` 语义重写。 -- 若协调数据库的 schema 不受支持或一致性校验失败,整个会话组不得标记成功。 - -### 5.4 记忆 - -来源至少包括: - -`%APPDATA%\bitfun\data\memories\memories.sqlite` - -`%USERPROFILE%\.bitfun\memories` - -包含: - -- 当前 Memory 所有者能够解析的结构化记忆; -- 与记忆关联、且 schema 明确支持的索引和附件; -- 文件型记忆目录中非临时、非生成物的数据。 - -规则: - -- 数据库通过一致性备份读取,不复制活动中的 SQLite 文件组合。 -- 迁移器不得在迁移阶段触发 embedding、总结、压缩或其他模型调用。 -- 可重新生成的全文/向量索引默认不迁移;导入事实数据后由当前版本按需重建。 -- 重复记忆优先使用稳定 ID 和规范化内容哈希去重。 -- 同 ID 不同内容时保留目标记录,并为源记录分配新 ID 或进入显式冲突列表,不能静默覆盖。 -- 文件型记忆只允许在声明的根目录内读取,拒绝路径穿越和链接越界。 - -### 5.5 远程连接与设备 - -包含两类需要分别原子处理的子域。 - -#### Remote Connect、设备、账户和机器人 - -可迁移项包括: - -- 设备显示名称、用户可识别的连接资料; -- 已授权设备/控制端的非敏感元数据; -- Remote Connect、IM Bot 等仍受支持的配置; -- 可由当前安全存储接收的认证资料。 - -默认排除: - -- 在线状态、心跳、relay lease、临时会话 token; -- 进程和 Socket 发现信息; -- 已过期的一次性配对码; -- 当前协议不再接受的缓存。 - -设备 ID 和加密身份不能一概复制。领域适配器必须把数据分成: - -1. 可移植资料:直接转换; -2. 可安全迁移的秘密:通过 Vault/系统安全存储转写; -3. 机器绑定身份:生成当前身份并提示重新配对; -4. 不受支持记录:保留可读报告,不注册为有效连接。 - -不得为了“看起来迁移成功”而复用可能导致两个设备共享同一活跃身份的密钥。 - -#### SSH 连接与远程工作区凭据 - -作为独立原子子域处理: - -- SSH 主机、跳板链、端口和用户名配置; -- 远程工作区登记; -- `known_hosts`/主机指纹; -- 密码、私钥口令等 Vault 引用。 - -目标端已有连接资料优先。`known_hosts` 冲突必须展示指纹差异,不能自动接受新的主机身份。私钥文件只迁移引用;只有产品已有明确“导入密钥”流程且用户选择后,才复制密钥,并保留严格文件权限。 - -## 6. 用户流程 - -### 6.1 首次启动引导 - -首次启动引导由 Data Migrator 的 onboarding 模式承载,避免为了显示引导而先初始化主应用 WebView、配置、日志、插件或会话服务。启动顺序应是: - -1. 解析当前平台的 OpenBitFun 数据根目录。 -2. 读取最小迁移 onboarding 状态。 -3. 运行只读的 legacy probe。 -4. 如果没有受支持的旧数据,继续正常启动。 -5. 如果用户已选择不再提醒,继续正常启动。 -6. 如果存在可迁移数据,生成 onboarding 请求,以最小平台进程启动能力启动 Data Migrator,然后直接结束本次主应用启动。 -7. Data Migrator 展示首次启动引导;用户选择“立即迁移”时在同一迁移器进程中继续扫描、确认和迁移。 -8. 用户选择“稍后提醒”或“不再提醒”时,迁移器原子写入独立 onboarding 状态,再携带一次性已处理 run ID 重启主应用。 -9. 重启后的主应用校验 onboarding 状态/run ID,通过检测后才创建日志和打开正常产品存储,避免重复拉起迁移器。 - -这段检测必须位于 `src/apps/desktop/src/lib.rs` 中 `LogConfig::new` 和 `initialize_global_config` 之前。检测器自身只能使用独立、极小且不会打开产品数据库的日志/诊断方式;启动迁移器也只能依赖最小平台进程能力,并在 Windows 明确隐藏控制台窗口。 - -引导页应解释: - -- 旧数据不会被删除; -- 迁移期间 OpenBitFun 需要关闭; -- 可以选择迁移范围; -- 稍后可以从设置再次进入; -- 迁移器会在结束后重新打开 OpenBitFun。 - -首次提示不应直接开始迁移,也不应把“关闭窗口”解释为用户同意。关闭 onboarding 窗口默认等价于“稍后提醒”,并重新打开主应用。 - -### 6.2 设置中的常驻入口 - -在 `src/web-ui/src/app/scenes/settings/settingsRegistry.ts` 对应的“数据与维护”分类新增“数据迁移”。 - -入口至少展示: - -- 是否检测到旧 BitFun 数据; -- 上次扫描时间和来源; -- 最近一次迁移结果; -- 已成功/失败/跳过的领域; -- “扫描旧数据”“开始迁移”“查看报告”“重试失败项”; -- 已选择不再提醒时恢复首次引导提醒的选项。 - -从设置开始迁移时,如果存在正在运行的 Agent、终端任务、文件写入或未保存操作,主应用应先展示关闭影响。迁移请求确认后,仍走与首次启动相同的独立迁移器流程,Web UI 不直接读写用户数据。 - -### 6.3 Data Migrator 向导 - -建议步骤: - -1. 欢迎与来源选择。 -2. 只读扫描。 -3. 范围选择和依赖说明。 -4. 冲突、不可迁移项、磁盘空间和进程预检。 -5. 用户最终确认。 -6. 关闭/等待相关应用。 -7. 迁移进度。 -8. 结果与报告。 -9. 重新启动 OpenBitFun。 - -范围选择中,“工作区、会话与 Agent 任务状态”是一个整体选项;`coordination.sqlite` 作为强制依赖显示在详情中。 - -进度必须以领域、阶段和计数表达,不能用无法验证的伪百分比。取消只允许发生在安全边界: - -- 扫描和计划阶段可以立即取消; -- 暂存阶段可在当前可中断步骤后取消; -- 单个领域进入提交临界区后,必须完成提交或回滚到已知状态再响应取消。 - -## 7. 独立迁移器架构 - -### 7.1 为什么是独立应用 - -迁移器应是单独的可执行程序和应用包,而不是主应用中的隐藏窗口,原因包括: - -- 可以在主应用所有数据库、文件 watcher、日志和运行时完全关闭后工作; -- 不共享主应用 WebView profile,避免迁移浏览器状态时占用自己; -- 能独立获取目标数据锁; -- 崩溃恢复和升级边界清晰; -- 可由安装器、首次启动或设置入口统一启动; -- 可单独进行代码签名、包完整性校验和进程身份校验。 - -安装器可以提供“安装完成后迁移旧 BitFun 数据”的入口,但只负责启动同一个 Data Migrator,不能复制一套迁移实现。 - -### 7.2 进程交接协议 - -主应用与迁移器之间使用版本化请求文件,不把密钥或大段配置放在命令行。协议支持两种入口:首次启动的 `onboarding` 模式由迁移器收集选择;设置页的 `execute` 模式携带用户已确认的初始范围,但迁移器仍重新扫描并做最终确认。请求至少包含: - -- 请求协议版本; -- 随机 run ID 和一次性 nonce; -- 调用来源:首次启动、设置或安装器; -- 用户选择的来源引用与领域; -- 主应用进程 ID; -- 安装通道/产品身份; -- 请求创建时间和过期时间。 - -请求文件保存在当前用户专属、ACL 受限的迁移目录。迁移器必须校验: - -- 请求属于当前 OS 用户; -- 请求未过期且 nonce 未消费; -- 来源和目标由可信路径解析器重新解析,而不是直接信任任意绝对路径; -- 发起程序和待重启程序属于受信任的 OpenBitFun 安装; -- 协议版本受支持。 - -交接流程: - -1. 主应用落盘并 fsync 请求。 -2. 主应用使用共享进程管理器启动迁移器;Windows 子进程不得闪现控制台窗口。 -3. 迁移器读取请求并开始等待。 -4. 主应用停止接收新任务,优雅关闭 Agent Runtime、数据库、watcher 和窗口。 -5. 迁移器确认相关进程退出并获取独占迁移锁。 -6. 执行迁移。 -7. 写入最终结果和 onboarding 状态。 -8. 通过可信安装解析器定位主程序并重新启动。 -9. 新主应用读取 run ID,展示一次性迁移结果摘要。 - -不能接受请求文件传入的任意 executable path 并执行,避免形成命令执行入口。 - -### 7.3 运行拓扑 - -建议新增一个最小交付 profile: - -`src/apps/data-migrator` - -它只链接: - -- 稳定迁移 contracts; -- 通用迁移编排引擎; -- V1 五类数据的离线适配器; -- 平台路径、安全存储、文件系统和进程服务; -- 迁移器自身的轻量 UI。 - -它不应链接完整 `product-full`、启动 Agent Runtime、加载用户插件或初始化正常产品会话服务。 - -如果迁移器采用 Tauri/WebView UI,应使用独立 bundle ID、独立 WebView data directory 和最小命令白名单。UI 仍只调用 typed adapter,不直接使用 Tauri 文件系统 API。 - -## 8. 分层与代码所有权 - -以下是建议的物理落点;实现时可以根据现有 crate 粒度调整文件名,但不能改变所有权方向。 - -| 层 | 建议落点 | 责任 | -|---|---|---| -| 稳定合同与产品领域 | `src/crates/contracts/product-domains/src/legacy_migration/` | DTO、领域 ID、状态机、报告、兼容版本、远程姿态 | -| 通用服务 | `src/crates/services/services-integrations/src/legacy_migration/` 或最邻近的专用 service crate | 发现、锁、暂存、原子文件操作、SQLite 一致性快照、日志、恢复 | -| Product Assembly | `src/crates/assembly` 下的迁移装配模块 | 选择当前产品支持的领域适配器和 migrator delivery profile | -| 领域所有者 | 设置、Session、Memory、Remote、MiniApp 等现有 owner 附近 | 旧格式 reader、转换器、去重/冲突和当前格式 writer | -| Desktop | `src/apps/desktop` | 启动前 probe、引导、进程交接、重启后的结果提示 | -| Data Migrator | `src/apps/data-migrator` | 独立 UI、调用编排器、展示进度和报告 | -| Web UI | 设置注册表及数据迁移 feature | 展示状态和发起本机交接,不读写旧文件 | -| Installer | `OpenBitFun-Installer` | 安装和可选启动迁移器,不拥有迁移逻辑 | - -当前相关事实应复用而不是复制: - -- 系统 Skill 目录:`src/crates/execution/agent-runtime/src/skills/roots.rs` 中的 `OPENBITFUN_SYSTEM_SKILL_DIR`。 -- 内置 MiniApp 身份:`src/crates/contracts/product-domains/src/miniapp/builtin.rs` 中的 `BUILTIN_APPS` 及 `.builtin-manifest.json`。 -- Agent Runtime 协调数据库路径:`src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs` 中的 `data/agent-runtime/coordination.sqlite`。 -- 设置分类注册:`src/web-ui/src/app/scenes/settings/settingsRegistry.ts`。 -- 远程命令姿态:`src/crates/contracts/product-domains/src/remote_surface/table.rs`。 - -### 8.1 稳定合同 - -核心合同建议包含: - -- `LegacySourceDescriptor`:产品、版本、平台、解析后的根、来源指纹; -- `MigrationDomainId`:五个用户组及内部原子子域; -- `ScanFinding`:数量、大小、版本、风险、依赖和不可迁移原因; -- `MigrationSelection`:用户选择和系统自动补入的依赖; -- `MigrationPlan`:有序步骤、预计写入、冲突策略和计划哈希; -- `MigrationProgressEvent`:领域、阶段、已处理/总数、当前安全取消点; -- `MigrationConflict`:稳定代码、来源摘要、目标摘要和处理结果; -- `MigrationRunReport`:最终状态、领域结果、警告、重认证要求; -- `MigratorProtocolVersion`:主应用、安装器与迁移器的交接能力协商。 - -持久化 shape 只能追加带默认值的字段。旧迁移器和新主应用之间必须按能力协商,不能以安装包版本相同为前提。 - -### 8.2 领域适配器 - -每个领域实现统一的离线生命周期: - -1. `probe`:确认是否存在及格式版本; -2. `scan`:只读枚举和基本校验; -3. `plan`:转换、依赖、冲突和空间估算; -4. `stage`:写入隔离暂存区; -5. `validate_stage`:按当前 owner 读取暂存结果; -6. `commit`:事务或原子替换; -7. `validate_commit`:从目标读取验证; -8. `rollback`:仅处理该领域尚未确认成功的目标修改; -9. `summarize`:输出去敏报告。 - -禁止用“复制未知目录”的默认适配器兜底。 - -### 8.3 桌面命令 - -主应用侧命令保持窄接口并使用结构化 `request`,例如: - -- `get_legacy_migration_status`; -- `scan_legacy_migration`; -- `prepare_legacy_migration`; -- `get_legacy_migration_report`; -- `set_legacy_migration_prompt_preference`。 - -真正的 `execute` 只存在于 Data Migrator 本地进程,不暴露给普通 Web UI 或远程调用面。 - -这些命令必须登记到 Product Operation Registry: - -- remote workspace:本机产品数据操作,不路由到远程工作区; -- remote control:显式 unsupported; -- Peer Device:`ControllerLocal`,不能默认代理到 peer; -- Detached Dispatch:不支持。 - -## 9. 迁移日志与报告 - -迁移元数据建议位于目标数据根的专用目录,例如逻辑上的: - -`data/migrations/bitfun-to-openbitfun/` - -具体路径仍由统一路径管理器提供。目录至少包含: - -- `onboarding.json`:提示偏好和最近来源; -- `runs//request.json`:去敏后的请求; -- `runs//plan.json`:不可变计划及哈希; -- `runs//journal.jsonl`:追加式状态事件; -- `runs//report.json`:机器可读最终报告; -- `runs//report.html` 或 UI 可渲染摘要; -- `runs//stage/`:未提交暂存; -- `runs//backup/`:本次修改涉及的目标备份; -- `lock`:本机独占迁移锁。 - -日志状态至少包括: - -- `discovered`; -- `scanned`; -- `planned`; -- `waiting_for_processes`; -- `staging`; -- `validating_stage`; -- `committing`; -- `validating_commit`; -- `completed`; -- `completed_with_warnings`; -- `cancelled`; -- `failed_recoverable`; -- `failed_manual_action_required`。 - -每个领域独立记录 `not_started / staged / committed / verified / failed / skipped`。Journal 使用追加和 fsync;汇总文件通过临时文件加原子 rename 更新。 - -报告中不能包含凭据、完整消息正文、完整记忆正文或私钥内容。文件路径默认缩短到逻辑根下的相对路径;日志遵循仓库规则,只使用英文且不含 emoji。 - -## 10. 执行状态机 - -### 10.1 Discover - -- 解析受支持源; -- 读取最小版本标记; -- 计算不含敏感内容的来源指纹; -- 拒绝跨用户、权限异常或来源与目标相同的配置。 - -### 10.2 Scan - -- 所有领域只读扫描; -- 识别 schema 版本、实体数量、逻辑大小和风险; -- 检查源数据库完整性; -- 不生成目标记录,不执行用户代码。 - -### 10.3 Plan - -- 展开用户选择的强制依赖; -- 为每个领域选择明确的 `source_version -> current_version` 转换路径; -- 计算目标冲突、ID 映射策略、空间需求和执行顺序; -- 生成不可变计划哈希; -- 向用户展示后再次确认。 - -若用户选择会话组,计划必须包含 `coordination.sqlite`;缺失或不兼容时在确认前显示阻断原因。 - -### 10.4 Acquire - -- 等待 BitFun、OpenBitFun 及相关 Agent Runtime 退出; -- 获取源读取保护和目标独占迁移锁; -- 再次确认来源指纹未变化; -- 检查可用空间,至少覆盖暂存、备份和安全余量; -- 记录恢复点。 - -### 10.5 Stage - -- 按拓扑顺序执行领域 reader 和转换器; -- 先建立跨实体 ID 映射,再写引用; -- SQLite 使用一致性备份到 stage; -- 文件内容复制到 stage 后校验大小和哈希; -- 用户扩展内容保持惰性,不加载、不执行。 - -建议依赖顺序: - -1. 设置的非敏感结构和凭据引用; -2. Skills、MiniApps、Agents; -3. 工作区、会话、runtime-events 和协调数据库; -4. 记忆; -5. Remote Connect/设备和 SSH 子域; -6. 最终交叉引用修复。 - -实际提交顺序由 plan 固化,恢复时不得重新推测。 - -### 10.6 Validate Stage - -- 用当前版本 owner 的 reader 打开暂存结果; -- 执行 schema、外键、引用闭包和数据库完整性检查; -- 确认没有源路径越界、未声明文件或秘密泄漏到普通暂存; -- 会话组必须证明 Session/Subagent/Task 与协调数据库的引用闭包。 - -### 10.7 Commit - -每个原子子域单独提交: - -- 数据库使用 owner 事务或受支持的 import API; -- 独占目录可使用同卷原子 rename; -- 与目标已有数据合并时必须走 owner writer,不能替换整个目标目录; -- 写入提交意图、执行提交、读取验证、写入已验证标记; -- 进入提交临界区后不能留下“可能写了一半但无记录”的状态。 - -全局使用 saga:某领域提交后,后续领域失败时保留已经验证的数据,报告“部分完成”,并允许只重试失败领域。除非 owner 能证明补偿操作无损,否则不自动回滚已成功领域。 - -### 10.8 Finalize - -- 清理未需要保留的临时 stage; -- 保留报告、必要恢复信息和用户选择; -- 释放锁; -- 重新启动可信 OpenBitFun 主程序; -- 主应用展示一次性摘要; -- 旧 BitFun 数据保持不变。 - -## 11. 合并、冲突与 ID 策略 - -通用默认规则: - -| 情况 | 默认处理 | -|---|---| -| 目标不存在,来源有效 | 导入 | -| 稳定 ID 与内容哈希均相同 | 视为重复,跳过 | -| 同名但 ID 不同 | 两者保留;必要时给来源显示名加“来自 BitFun” | -| 同 ID、内容不同且可安全重映射 | 目标保留,来源生成新 ID,重写全部来源引用 | -| 同 ID、内容不同且不可安全重映射 | 不写入该实体,记录显式冲突 | -| 目标已有显式设置/凭据 | 目标优先 | -| 来源格式未知或校验失败 | 保留源,跳过目标写入,报告原因 | - -领域补充规则: - -- Settings:按 key 级别合并,只导入白名单及有明确转换器的项。 -- Agents/Skills/MiniApps:按稳定扩展 ID 和内容版本判断;不允许旧内置内容覆盖当前内置内容。 -- Sessions:ID 重映射必须覆盖工作区、Session、Turn、附件、runtime event、Subagent/Task 和协调库引用,不能只改顶层 Session ID。 -- Memory:内容哈希用于辅助去重,但不能把内容相似当作完全相同。 -- Remote:不能让两个设备共享一个应当唯一的活跃设备身份;安全优先于无提示连续登录。 -- SSH:host key 冲突要求用户确认,不能自动覆盖。 - -V1 不提供逐条通用冲突编辑器。可自动安全处理的按规则处理;需要用户判断的项目在计划或报告中分领域展示,并提供“跳过该项/该领域”的安全选择。 - -## 12. 安全与隐私 - -迁移器处理的是不可信的本地输入,必须按导入器而不是备份复制工具进行防护: - -- 所有文件操作限定在解析后的允许根目录内。 -- Windows 检查 reparse point/junction;其他平台检查 symlink,默认不跟随越界链接。 -- manifest 中的绝对路径和 `..` 越界一律拒绝。 -- 对文件数量、单文件大小、总大小、JSON 深度和数据库页数设置合理的资源上限,并把超限报告给用户。 -- SQLite 先做只读完整性和 schema 检查;不执行来源提供的扩展、触发器脚本或任意 SQL 文本。 -- 不执行 Agent、Skill、MiniApp、hook 或插件代码。 -- 凭据只在最短生命周期的受保护内存和系统安全存储通道中出现。 -- 日志、遥测和崩溃报告不能包含秘密或用户正文。 -- 迁移请求和报告使用当前用户 ACL;不允许跨 OS 用户迁移。 -- 迁移器和主程序互相验证产品身份、签名/安装位置和协议版本。 -- 默认不要求管理员权限,也不通过提权绕过 ACL。 -- 备份和暂存的保留策略必须可见;包含敏感材料的临时文件在失败后也要按策略安全清理。 -- 导入的可执行内容在首次实际启用时仍走当前产品的信任、权限和安全审查。 - -## 13. 远程场景边界 - -### 13.1 Remote Workspace - -迁移器运行在保存本机旧 BitFun 产品数据的设备上。迁移工作区登记和远端 POSIX 路径,但不连接 SSH 主机复制项目内容,也不把本地路径传给远端执行。 - -如果某条工作区记录需要当前版本不具备的远程能力,应保留记录并显示 unsupported/needs-repair,不能静默改成本地工作区。 - -### 13.2 Remote Control - -手机网页或 IM Bot 不能远程启动会关闭桌面应用的迁移流程。对应命令必须返回明确的“请在保存旧数据的桌面设备上操作”,不能空响应或在控制端本地执行。 - -迁移完成后,普通的结果通知可以通过已有远程事件机制展示,但不能把报告中的敏感路径或内容推送给远程控制端。 - -### 13.3 Peer Device Mode - -迁移命令为 `ControllerLocal`。每台设备只迁移自己的旧数据,不能由控制器把本机来源路径代理给 peer,也不能默认在 peer 上执行。 - -若未来需要迁移 peer 上的数据,必须在 peer 设备本地发起并完成独立的用户确认和进程交接。 - -### 13.4 Detached Dispatch - -V1 不支持在 Detached Dispatch 作业中迁移。迁移需要本机交互、关闭产品进程和独占用户数据锁,不属于无交互 durable job。 - -未来如提供 headless CLI,只能作为单独能力设计,具备明确源、目标、锁、报告和确认语义,不能复用调度作业做隐式迁移。 - -## 14. 打包、升级与重启 - -- Data Migrator 与 OpenBitFun 使用同一发布通道,但拥有独立应用身份和可执行文件。 -- 安装器验证迁移器存在且签名有效;缺失时不能退化为安装器自行复制文件。 -- 主应用和迁移器通过协议能力协商。新主应用遇到旧迁移器时应要求修复安装或更新,不能发送其不理解的请求。 -- updater 在迁移期间不得替换主应用或迁移器;迁移锁和更新锁要有确定的获取顺序。 -- 迁移器运行时不自更新。更新应在迁移开始前完成,或迁移结束后由主应用处理。 -- 重启路径从受信任安装注册信息解析,并继承正确安装通道;不执行请求中的任意路径。 -- 重启失败不影响已提交数据。结果页应提供“打开 OpenBitFun”和安装修复说明。 -- 卸载 BitFun 与清理旧数据是迁移以外的显式用户动作。 - -Windows 是首个必须完整支持的平台。macOS/Linux 如果存在历史 BitFun 版本,应使用同一领域协议和平台路径解析器,不能复制 Windows 绝对路径逻辑。 - -## 15. 测试与验证方案 - -### 15.1 固定样本 - -为每个受支持旧版本建立去敏、最小但关系完整的 fixture: - -- 空安装; -- 只有一个领域; -- 五个领域齐全; -- 含 Session/Subagent/Task/coordination 交叉引用; -- SQLite 存在未 checkpoint 的 WAL; -- 目标已有数据和 ID 冲突; -- 无效 JSON、未知 schema、损坏 SQLite; -- 路径穿越、junction/symlink、超大文件; -- 凭据可迁移与不可解密; -- 远程设备身份不可移植; -- 中英文及特殊字符路径。 - -fixture 必须由 owner reader 生成或验证,避免测试只证明迁移器和自造假格式彼此一致。 - -### 15.2 单元与契约测试 - -- 版本探测和转换器选择; -- 各领域 include/exclude 规则; -- `.system` Skills 排除; -- 内置 MiniApp 与用户数据拆分; -- 会话选择自动包含 `coordination.sqlite`; -- ID 映射和引用闭包; -- 目标优先、重复跳过、冲突可见; -- 报告去敏; -- 交接协议版本和过期 nonce; -- Product Operation Registry 的本机/远程姿态。 - -### 15.3 集成测试 - -- 在隔离目录运行完整 scan → plan → stage → commit → verify; -- 对同一来源连续迁移两次,第二次不产生重复; -- 源目录迁移前后哈希一致; -- 目标 owner 能正常读取迁移结果; -- SQLite WAL 内容进入目标且 `-shm` 不进入目标; -- 会话及旧 Subagent 在导入后可被当前 owner 解析并恢复关系; -- 每个状态转换点注入崩溃,重启后能继续或给出确定状态; -- 某领域失败后,其他已验证领域保持可用,重试只处理失败项; -- 磁盘不足、权限不足和占用冲突不产生半提交。 - -### 15.4 进程与打包测试 - -- 主应用生成请求后优雅退出; -- migrator 等待所有 writer 并获取独占锁; -- 迁移结束后从可信安装位置重启; -- 请求路径注入不能启动任意程序; -- 安装、升级、修复安装和卸载组合; -- Windows 下迁移器和子进程不闪现控制台; -- 迁移期间 updater 不与 migrator 互相覆盖; -- 代码签名和包内容闭包。 - -### 15.5 手工验证 - -发布前至少覆盖: - -- 全新安装首次引导; -- “稍后提醒”“不再提醒”和设置常驻入口; -- 五类范围选择及强制依赖说明; -- 关闭主应用、迁移进度、取消安全点; -- 完成、有警告、可恢复失败和重启失败; -- 屏幕阅读器、键盘操作、本地化和长路径展示。 - -手工 UI 验证需要按仓库规则单独显式执行;本文档落地本身不启动开发服务器,也不构成运行时/UI 验证。 - -## 16. 分阶段实施 - -### Phase 0:格式冻结与样本 - -- 为五个领域指定 owner。 -- 从受支持 BitFun 版本生成去敏 fixture。 -- 明确每个旧 schema 的最低支持版本。 -- 固化 include/exclude 表、数据依赖和来源指纹算法。 -- 对 `coordination.sqlite` 的表关系及 Session 引用做闭包审计。 - -交付:版本支持矩阵、fixture、领域责任表。 - -### Phase 1:合同与无 UI 引擎 - -- 新增 migration contracts、状态机、journal、报告和锁。 -- 实现平台路径发现、只读 SQLite snapshot、暂存和领域原子提交工具。 -- 建立 crash injection 和 idempotency 测试框架。 -- 实现 dry-run CLI/test harness,仅用于开发和自动化验证。 - -交付:可在隔离目录完成扫描、计划和无损演练的核心。 - -### Phase 2:五类领域适配器 - -建议按依赖顺序实现: - -1. 设置和凭据; -2. Skills、MiniApps、Agents; -3. 工作区、Session、runtime-events、`coordination.sqlite`; -4. Memory; -5. Remote Connect、设备和 SSH。 - -每个适配器必须同时交付旧格式 fixture、转换测试、冲突测试、当前 owner 读取验证和去敏报告。 - -### Phase 3:独立 Data Migrator - -- 增加最小 migrator delivery profile 和 UI。 -- 实现进程交接、独占锁、恢复、取消和可信重启。 -- 建立独立 bundle ID、WebView profile、签名和安装布局。 -- 完成 Windows 进程/锁/打包测试。 - -### Phase 4:产品入口 - -- 将轻量 probe 放到 Desktop 正常存储初始化之前。 -- 增加首次启动引导。 -- 在“数据与维护”增加常驻入口和历史报告。 -- 将命令加入 Product Operation Registry,并验证 Remote/Peer/Dispatch 显式边界。 -- 让安装器仅启动同一个迁移器。 - -### Phase 5:发布与观测 - -- 小范围发布,观测仅包含去敏结果码、领域状态、耗时和失败阶段。 -- 建立不含用户正文的失败诊断导出。 -- 根据真实旧版本补充转换器和 fixture。 -- 达到验收门槛后扩大推送,不以自动删除旧数据作为收尾。 - -## 17. V1 验收标准 - -V1 只有同时满足以下条件才算完成: - -- Windows 能正确发现 `%APPDATA%\bitfun`,且实现不硬编码具体用户名。 -- 首次启动检测发生在正常日志和全局配置初始化之前。 -- 用户可以立即迁移、稍后提醒或不再提醒。 -- 设置“数据与维护”中始终存在迁移入口。 -- 迁移在独立、签名的 Data Migrator 中执行,主应用在写入前已退出。 -- 五个用户组均有明确 owner、旧版本 reader、转换器、验证器和报告。 -- `agents` 全部正式用户内容、`skills` 中非 `.system` 内容以及非内置 MiniApps 按规则迁移。 -- 会话组强制包含 `data/agent-runtime/coordination.sqlite`,并验证 Session/Subagent/Task 引用闭包。 -- `ownership`、`ipc-v*`、PID/lock/socket/discovery 和 SQLite `-shm` 不进入目标。 -- SQLite WAL 中已提交内容不会因只复制主文件而丢失。 -- 目标已有数据默认不被覆盖,所有冲突可见。 -- 迁移可重试、可恢复且重复执行不重复导入。 -- 旧 BitFun 数据在成功、失败和取消后均保持不变。 -- 凭据和用户正文不进入日志、命令行、遥测或普通报告。 -- 远程调用返回明确 unsupported,Peer 命令为 `ControllerLocal`,无静默本地回退。 -- 完成后能可信重启 OpenBitFun;重启失败不回滚已验证数据。 -- fixture、领域集成、崩溃恢复、进程交接和打包测试通过。 -- 每个受支持旧版本都有至少一个包含会话与 `coordination.sqlite` 的端到端样本。 - -## 18. 已确定的 V1 决策 - -以下事项不再留给实现阶段临时决定: - -1. 使用独立 Data Migrator,不在运行中的主应用内直接迁移。 -2. 安装器只启动迁移器,不拥有第二套迁移逻辑。 -3. 旧源数据只读且永不自动删除。 -4. 迁移按领域 owner 解析,不递归复制整个用户目录。 -5. 用户界面采用五个高层组。 -6. `agents` 全部正式用户 Agent 纳入。 -7. `skills` 排除 `.system`,其他用户 Skills 纳入。 -8. `data/miniapps` 只迁移非内置 MiniApp;内置代码重建,用户数据另行适配。 -9. `data/agent-runtime/coordination.sqlite` 是会话组的强制组成部分。 -10. V1 不支持逐个 Session 选择。 -11. 目标端已有数据默认优先,冲突显式报告。 -12. 领域原子提交加全局 saga,不虚构跨领域全局事务。 -13. 首次检测位于常规产品存储打开之前。 -14. “数据与维护”永久保留迁移入口。 -15. 迁移只在数据所在本机执行,远程场景明确降级。 -16. 用户扩展内容在迁移期间保持惰性,绝不执行。 - -## 19. 后续可扩展项 - -不阻塞 V1,但合同应允许后续增加: - -- 受支持旧版本的增量转换器; -- macOS/Linux 历史数据路径; -- 经明确设计的 headless 导入工具; -- 用户导出的离线迁移包; -- 更细的领域内预览和冲突选择; -- 完成后的显式旧数据清理向导; -- 对不可移植设备身份的辅助重新配对流程。 - -这些扩展仍必须遵循源只读、owner-aware、目标优先、可恢复和远程显式降级的基本原则。 diff --git a/OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md b/OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md deleted file mode 100644 index 5bf6ad0382..0000000000 --- a/OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md +++ /dev/null @@ -1,569 +0,0 @@ -# BitFun → OpenBitFun 旧版持久化数据迁移清单 - -> 状态:静态核对完成(实现前清单;尚未实现或运行验证) -> 品牌更名提交:`8784bbc4258131b4e74e6f9192a23b43c454fa94` -> 最后更新:2026-09-05 - -## 1. 目的与结论口径 - -本文梳理品牌名从 BitFun 改为 OpenBitFun 后发生路径或存储命名变化的持久化数据,为后续迁移系统提供输入。每一项均区分: - -- **P0 必须迁移**:丢失会直接造成配置、会话、身份、凭据或用户创作内容不可用。 -- **P1 应迁移**:属于有价值的历史数据、工作状态或可感知偏好;缺失会造成明显体验或历史损失。 -- **P2 可选迁移**:可重建或价值较低,迁移主要用于减少升级扰动。 -- **不迁移**:缓存、日志、锁、PID、临时事务目录、可安全重建的索引。 -- **待核对**:尚未完成 owner、格式或生命周期核对,不能据此直接实现迁移。 - -最重要的架构结论是:**迁移系统不能只做旧目录到新目录的递归复制**。至少 `config/app.json`、`data/workspace_data.json`、cron、项目权限、Remote Connect 和 Subscription Auth 存在品牌字段、版本/schema 拒绝、旧格式升级或密钥域变化,必须由各 store 的迁移器读取、校验、转换并原子写入。 - -## 2. 证据边界 - -- 已做:当前 checkout 的静态代码检查、提交元数据/路径级检查、本机旧目录的名称、数量和大小检查。 -- 未做:未读取用户配置、会话、Token、Cookie、密钥或终端记录的实际内容。 -- 未做:未启动应用或开发服务器,未执行 UI/WebView 验证,未执行远程工作区、远控、Peer Device、Detached Dispatch 运行验证。 -- 未做:未运行迁移,未删除或移动任何旧数据。 - -表格中的“已核对”仅表示已找到当前代码 owner、路径或生命周期证据,不代表迁移实现已验证。 - -## 3. 根目录总览 - -| 状态 | 数据域 | 旧位置 | 新位置 | 优先级 | 总体建议 | -|---|---|---|---|---|---| -| 已核对 | 产品用户目录 | Windows `%APPDATA%/bitfun`;macOS `~/Library/Application Support/bitfun`;Linux `$XDG_CONFIG_HOME/bitfun`(通常 `~/.config/bitfun`) | 各平台同一 config root 下的 `openbitfun` | 分项判断 | 不可整目录复制;按 `config`、`data`、`skills` 等 store 迁移,排除日志、缓存和临时文件 | -| 已核对 | 产品 Home | `~/.bitfun` | `~/.openbitfun` | 分项判断 | 会话、工作区、记忆、身份、远控、dispatch 等分别迁移;排除锁、trace 和索引 | -| 已核对 | Tauri 应用数据 | `%APPDATA%/com.bitfun.desktop` | `%APPDATA%/com.openbitfun.desktop` | P1 | 窗口状态等按文件迁移,不复制异常恢复标志 | -| 已核对 | WebView2 用户数据 | `%LOCALAPPDATA%/com.bitfun.desktop/EBWebView` | `%LOCALAPPDATA%/com.openbitfun.desktop/EBWebView` | P1 | 不全是缓存;保留 Local Storage、IndexedDB;若承诺登录态则成组迁移 Cookies/Profile,排除浏览器缓存与运行锁 | -| 已核对 | Windows SSH 数据根 | `%LOCALAPPDATA%/BitFun/ssh` | `%LOCALAPPDATA%/OpenBitFun/ssh` | P0 | 连接、远程工作区、host key 信任和密码 vault 成组迁移;旧 connection ID 和旧单对象 workspace 格式需转换 | -| 已核对 | Git worktree / dispatch 外部实体 | 由旧注册表记录 | 由新注册表记录 | P0/P1 | 不得仅改 JSON 中的路径或直接搬目录;优先保留旧实体原位并让新注册表继续引用,或使用 Git-aware 专用迁移 | -| 已核对 | Desktop 开发 profile | `%APPDATA%/com.bitfun.desktop.dev`、`%LOCALAPPDATA%/com.bitfun.desktop.dev/EBWebView` | 对应 `com.openbitfun.desktop.dev` | P2(开发者) | 与正式 profile 完全隔离;只为确实使用过 dev build 的开发者按第 7 节规则迁移,不应混入普通用户升级 | -| 已核对 | Windows 安装与系统集成 | `%LOCALAPPDATA%/BitFun`、BitFun 注册表项/快捷方式/PATH | OpenBitFun 对应项 | P0(升级流程),非用户数据 | 旧安装器当前不会被新名称的检测器发现;需显式接管旧安装记录、安装新包并在成功后移除旧集成,详见第 8 节 | -| 已核对 | Android/iOS/HarmonyOS 应用沙箱 | 旧 package/bundle ID 的私有沙箱与安全存储域 | 新 package/bundle ID 的独立沙箱与安全存储域 | P0 | 包标识变化会让系统把新版视为另一个应用;数据库、身份和凭据不能靠文件名兼容读取,详见第 9 节 | -| 已核对 | Hosted MiniApp/Skin Market | `/srv/bitfun-*-market`、`/etc/bitfun-*-market` | `/srv/openbitfun-*-market`、`/etc/openbitfun-*-market` | P0(服务迁移) | 数据库、artifact、backup、secret env 和服务单元成组迁移;持久 JSON 与 Skin archive 还需结构化转换,详见 5.10 | -| 已核对 | 普通浏览器 origin storage | Market、Mobile Web、Design Lab 各自 origin 下的旧品牌 Local Storage/IndexedDB | 同 origin 下的新 key/DB | 分项判断 | Desktop 迁移器不得扫描外部浏览器 profile;同 origin 页面在各 store hydrate 前双读旧 key 并写新 key,跨 origin 则需站点级 bridge/export-import,详见第 7 节 | - -当前路径契约主要见: - -- `src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs` -- Desktop 的 Tauri identifier 配置 - -`dirs::config_dir()` 决定产品用户目录,所以不能把 Windows 路径硬编码成跨平台迁移规则。`~/.bitfun`/`~/.openbitfun` 则由用户 home 决定;`BITFUN_USER_ROOT`、`BITFUN_HOME` 等旧覆盖变量还可能让真实源位置偏离上述默认值,必须在创建任何新 store 之前按 5.9 处理。 - -## 4. `%APPDATA%/bitfun` 深入清单 - -### 4.1 配置与备份 - -| 状态 | 相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `config/app.json` | 全局产品配置;包含模型、运行时、语音等设置,部分字段可能直接包含 API key | P0 | 使用配置 owner 做 schema-aware 转换;更新产品标识并迁移 retired/renamed 字段,不能原样复制 | -| 已核对 | `config/hooks.json` | 用户级 native hook 配置 | P0 | 迁移并按当前 Codex hook contract 校验;保持启用/禁用和执行策略,不能只迁 hook 脚本不迁配置 | -| 已核对 | `config/external-sources.json` | 外部工具、MCP、subagent 等来源的选择、审批、冲突决策和已确认生态 | P0 | 完整迁移并校验 schema;这是安全/信任决策,不是发现缓存。原生候选 ID 的 `bitfun.desktop:*` / `bitfun.cli:*` 需改成 `openbitfun.desktop:*` / `openbitfun.cli:*`,并由当前 owner 重新计算包含候选 ID 的 native conflict key/fingerprint;不能只做 JSON 字符串替换 | -| 已核对 | `config/announcement-state.json` | 公告已读/展示状态 | P2 | 可迁移以避免升级后重复提示;失败不阻塞主迁移 | -| 已核对 | `config/installer-state.json` | 最近安装路径等安装器状态 | P2 | 可选迁移;路径失效时保留记录并明确降级 | -| 已核对 | `config/backups/`、`data/backups/` | 配置或工作区索引的旧备份 | P1(档案) | 保留为 legacy archive;若导入新 active backup 命名空间,必须先转换为当前格式 | -| 已核对 | 根部 `config.toml` | CLI 配置 | P0 | 迁移正式文件;不迁移同名锁文件。`ui.theme_id` 的 6 个内置值需逐项从 `bitfun-{cyber,dark,ink-night,light,midnight,tokyo-night}` 映射为 `openbitfun-*`;当前读取器不认识旧 ID,原样保留会静默退回基础主题。用户自定义/未知 ID 不做前缀猜改 | -| 已核对 | 根部 `agents/` | 用户级自定义 Agent 定义 | P0 | 完整迁移,目标已有同名定义时保留双方并报告冲突 | -| 已核对 | 根部 `update.lock` | CLI self-update 最长 1 小时的 install 互斥锁 | 不迁移 | 只对旧 updater 进程有效;升级前停止旧 updater,由新 CLI 在新根重新建锁 | -| 已核对 | 根部 `last-update-check` | 文件 mtime 控制自动检查频率,内容记录执行检查时的 CLI 版本 | 不迁移/P2 | 不复制;让新 CLI 按自己的版本和启动时间重新建立检查节奏,避免旧 mtime 延迟或扰乱新版本检查 | -| 已核对 | 根部 `update-last-error` | 后台更新失败的一次性诊断;下一次交互启动读取、打印后删除 | 不迁移 | 旧错误针对旧 binary/updater,不应在 OpenBitFun 首启中重放;源文件留在旧根供显式诊断 | - -`app.json` 当前加载会校验 OpenBitFun 产品标识、配置版本和 schema;`workspace_data.json` 也校验产品标识。因此这两个文件是“读取旧结构—转换—写新结构”,不是“复制后改一个目录名”。 - -### 4.2 核心持久化数据 - -| 状态 | 相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `data/workspace_data.json` | 工作区注册、选择和相关持久化信息 | P0 | 结构化转换,更新产品标识;对已移动/不存在路径保留记录并标记不可用,不静默删除 | -| 已核对 | `data/memories/memories.sqlite` | 结构化记忆数据库 | P0 | 在新 store 打开前用 SQLite backup API 迁移,或在旧进程完全退出后 checkpoint;不能只复制主 DB 而忽略 WAL | -| 已核对 | `data/agent-runtime/coordination.sqlite*` | Agent/subagent/background task 的 durable coordination 状态 | P0/P1 | 同样使用 SQLite 一致性迁移;`-shm` 不是用户数据,WAL 必须被正确合入 | -| 已核对 | `data/agent-runtime/ownership/` | 运行时所有权/互斥状态 | 不迁移 | 新进程重新建立,迁移会制造陈旧 owner | -| 已核对 | `data/agent-runtime/ipc-v/` | Shared Runtime discovery JSON、instance lock;Unix 还含 UDS socket,Windows endpoint 是 named pipe | 不迁移/敏感运行态 | record 含 PID、owner、endpoint 和连接 token。品牌 hash domain 与 pipe/socket 名都已变化;停旧 Runtime 后由新进程重建。不得把旧 token 或 discovery record 复制进新根,也不得把它误归入 `coordination.sqlite` 的 durable 状态 | -| 已核对 | `data/permissions/tool-permissions.sqlite` | 按项目保存的工具授权和有界审计记录 | P0 | SQLite 一致性迁移;旧数组式项目权限需由专用迁移器转换,不能只复制旧配置 | -| 已核对 | `data/cron/jobs.json` | 用户定时任务 | P0 | 旧 v1 当前代码明确要求显式迁移;转换到当前 schema 后原子写入 | -| 已核对 | `data/cron/backups/` | 定时任务恢复备份 | P1(档案) | 保留旧备份;如进入当前备份集合需先做格式转换 | -| 已核对 | `data/terminals/` | 持久化终端 transcript,包括命令、输出、cwd、退出码 | P1 | 迁移 `index.json` 和正式日志分段;排除 `index.json.tmp`,按敏感数据处理 | -| 已核对 | `data/token_usage/` | 模型使用量、Token 和费用统计历史 | P1 | 迁移 `model_stats.json` 与 `records/*.json` | -| 已核对 | `data/usage-data/` | 用户生成的 Insights JSON/HTML 报告 | P1/P2 | 建议迁移;虽可能从原始数据重建,但属于用户显式生成的历史产物 | -| 已核对 | `data/ssh_acp_capabilities.json` | 远程 ACP 能力探测快照 | 不迁移/P2 | 可重新探测;不应让旧快照覆盖当前协商结果 | - -### 4.3 用户创作、扩展和工作状态 - -| 状态 | 相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `data/frontend-workbench/state.json`、`state.previous.json` | 当前、上一版和 pending 前端修订状态 | P0/P1 | 必须迁移并保持引用闭包 | -| 已核对 | `data/frontend-workbench/drafts/` | 用户尚未 apply 的前端草稿 | P0 | 完整迁移 | -| 已核对 | `data/frontend-workbench/revisions/` | bundled 及用户/Agent 创建的前端修订 | P1 | 至少迁移 state 引用的 revision 和所有 `creative-*`;纯 bundled revision 可由安装包重新物化 | -| 已核对 | `data/frontend-workbench/.copy-*` | 复制/提交事务暂存 | 不迁移 | 陈旧事务目录 | -| 已核对 | `data/miniapps//` | MiniApp 源码、元数据、用户 storage、自定义、版本历史及编译产物 | P0/P1 | 正式 app 目录整体迁移最安全;`compiled.html` 可重建,但一并迁移可避免首启不可用 | -| 已核对 | `data/miniapps/.drafts/` | 进程内沙箱草稿 | 不迁移 | 当前启动会隔离并清理跨进程遗留 draft | -| 已核对 | `data/miniapps/.market-install-*` 等 | 市场安装、更新、回滚临时事务 | 不迁移 | 不能把未完成事务当成正式 MiniApp | -| 已核对 | 用户级/项目级 `hook-imports` | 外部 hook 导入索引、用户 enablement、经验证的托管 snapshot | P0/P1 | index 与 bundle 成组迁移,并重新校验 schema、路径及信任语义 | -| 已核对 | `data/rules/` | 用户级规则 | P0 | 完整迁移;用户内容不能被新版本默认内容覆盖 | -| 已核对 | `data/plugins/` | 用户安装的产品插件包 | P0 | 包、manifest、信任/启用相关 store 成组迁移;旧 `bitfun.plugin.json` 必须由 owner 校验后写成 `openbitfun.plugin.json`,详见 4.3.1 | -| 已核对 | `skills/` | 用户安装或产品附带的 skills | 分项判断 | 用户/第三方 skill 必须迁移;当前版本可重新生成且未被用户修改的 bundled system skill 可重建。配置中引用 skill 的稳定 key 也必须转换,详见 4.3.1 | - -#### 4.3.1 插件与 skill 的持久 identity - -插件和 skill 除了目录改址,还存在被配置、信任记录和历史 Turn 引用的品牌 identity: - -| 状态 | 旧值 → 新值 | 持久位置/作用 | 迁移方式与注意事项 | -|---|---|---|---| -| 已核对 | 每个 package 的 `bitfun.plugin.json` → `openbitfun.plugin.json` | `data/plugins//` 与工作区 `.bitfun/plugins//` 的托管插件 manifest | P0。当前 discovery 只查新文件名,直接复制目录会得到 `missing_manifest`。用当前 `PluginPackageManifest` parser 校验旧文件,确认 package 目录名、声明文件和 SHA-256 后,在目标 package 中以新文件名原子写入;不要盲目保留两份 active manifest | -| 已核对 | 旧 package canonical path → 新 package canonical path | `data/runtime/plugin-trust.json` 的 trust/activation record 保存 `packageId`、version、adapter、**绝对 canonical `sourcePath`** 和 content hash | P0(已批准/激活插件)。搬目录会改变 identity,原样复制 trust store 会使批准和 activation 失配。迁移器必须重新发现目标 package,仅在旧记录的 package/version/adapter/content hash 与目标验证结果一致时,把 `sourcePath` 重绑定到目标;目标已有 trust decision 时保留目标,不能借迁移扩大信任 | -| 已核对 | `bitfun://managed-plugins/...`、`bitfun://plugins/...` → `openbitfun://...` | adapter 生成的 source URI、tool target URI,并参与部分 runtime `plugin_id` hash;可能出现在 Session/dispatch transcript 的历史 tool/plugin diagnostics | 不能对 Session 文本全局替换。历史记录保留原 identity 供展示/审计;恢复仍需解析旧记录时由 plugin compatibility layer 接受旧 URI,并映射到已验证的新 package identity。新记录只写新 scheme | -| 已核对 | `bitfun.plugin.package.v1` / `*:bitfun.plugin` → `openbitfun.plugin.package.v1` / `*:openbitfun.plugin` | OpenCode adapter 生成的 `PluginManifestRef`,不是 `bitfun.plugin.json` 内部的数值 `schemaVersion: 1` | 历史诊断/Session record 双读旧值;不要把这段字符串误写进 package manifest,也不要改变 package manifest 的数值 schemaVersion | -| 已核对 | `user::bitfun::`、`project::bitfun::`、`user::bitfun-system::` → 对应 `openbitfun` key | `config/app.json` 中全局禁用与 Agent profile 的 enabled/disabled skill 列表,以及工作区 `.bitfun/config/agent_profiles.json`/`mode_skills.json` 的 project override | P0(用户选择)。按完整 key 的 slot 段转换,不能替换 skill 名或第三方 slot。当前 `normalize_skill_keys` 只 trim/dedupe,不兼容旧 key;不转换会让用户的启用/禁用选择静默失效 | -| 已核对 | `source_slot=bitfun\|bitfun-system`、`source_id=bitfun` → OpenBitFun 名称 | `SkillInfo`/Skill tool result 与历史 Session 中的来源说明;实际文件归属由目录和 discovery root 决定 | 历史 Turn 保持可读,不必为展示重写整份 Session。当前运行时重新 discovery 后写新 identity;任何以旧 `source_id/source_slot` 判断“产品 owned、可删除”的操作在兼容窗口内必须先 canonicalize,且仍禁止删除 built-in | - -插件目录还会出现在项目级 `.bitfun/plugins/`,因此同一 package 转换逻辑必须同时用于用户级与本地/远程 workspace。远程 workspace 的 manifest 与 trust/path identity 应在远端 filesystem owner 上处理,不能把远端 POSIX 路径改写成控制端路径。 - -### 4.4 浏览器自动化与可重建数据 - -| 状态 | 相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `data/browser-control//` | 专用 Chromium user-data-dir,可能包含 Cookie、登录态、Local State、Preferences | P1/可选 | 若承诺保留自动化浏览器登录态,应迁移;`Local State`、Profile、Cookies 必须成组,并要求浏览器及旧版应用已退出 | -| 已核对 | Chromium `Cache`、`Code Cache`、`GPUCache`、shader cache、Crashpad、`Singleton*`、`DevToolsActivePort` | 缓存、崩溃或运行锁 | 不迁移 | 新版本重建 | -| 已核对 | `runtimes/` | OpenBitFun 管理的 Node/Python/Office 等运行时 | 不迁移/P2 | 属于可重新供应的产品依赖;若为节省下载而复用,必须验证版本、完整性和内部绝对路径,不能盲拷 | -| 已核对 | `data/models/speech/` | 本地语音模型资源 | P2 | 可重新下载;可做校验后硬链接/复制的优化迁移,但不能让模型资源阻塞 P0 数据迁移 | -| 已核对 | `cache/model-downloads/speech/` | 未完成或可重试的语音模型下载 | 不迁移 | 让新版本重新下载 | -| 已核对 | `data/sdk-host/temp/scripts/` | SDK Host 临时脚本 | 不迁移 | 名称和生命周期均表明是临时执行产物 | -| 已核对 | 根部 `temp/scripts/` | Terminal shell integration 生成脚本 | 不迁移 | 新版本按当前 shell/runtime 重新生成;旧脚本可能嵌入旧可执行文件或绝对路径,不能复用 | -| 已核对 | `cache/`、`temp/` | 可重建缓存与临时文件 | 不迁移 | 不复制 | -| 已核对 | `logs/`、`cli-logs/` | 应用与 CLI 日志 | 不迁移 | 用户已明确无需迁移;如需诊断可把旧目录留作只读历史,不纳入新活动日志目录 | - -### 4.5 凭据和安全敏感数据 - -| 状态 | 相对路径/外部存储 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `data/subscription_auth.json` | 订阅账户的 v2 metadata:provider、过期时间、account metadata、secret set/chunk 计数,以及注销/并发保护用的 `provider_revisions` tombstone | P0 | 结构化迁移并保留 provider revision;相同 provider 两侧都有记录时目标 credential 优先,但 revision 取较大值。不能只复制 metadata,因为 secret 在外部 vault;也不能漏掉已注销 provider 的 revision,否则旧 refresh 可能复活凭据 | -| 已核对 | Windows/Linux secret service `openbitfun.bitfun.subscription-auth.v1` | 更名前的订阅 secret 域;v2 entry 名为 `/v2///`,更旧格式为 provider 单 entry | P0 | 从旧 service 逐 chunk 读取、重组并校验,再以新随机 set ID 写入 `openbitfun.subscription-auth.v1`;全部新 secret 写入成功后才提交新 metadata。旧单 entry 是组合 JSON,需复用提交前已有的 legacy importer;不得在日志中输出 entry 内容 | -| 已核对 | macOS `data/.subscription_auth_vault.key` + `data/subscription_auth_vault.json` | prompt-free 文件凭据 vault | P0 | 用旧 key 解密旧 vault,再通过新 owner 按 provider 写入目标 vault并提交 metadata;不能在目标已有数据时复制两文件覆盖,也不能只迁其中一个 | -| 已核对 | `data/subscription_auth.cleanup.json` | 未完成的 vault secret 清理 journal | P0(安全状态) | 记录的是旧 vault service 中待清理 entry,不能原样复制后让新 service 执行。源保持只读;迁移 active credential 后把旧域的待清理项列为显式后续清理,且不能删除仍被旧 metadata 引用的 entry | -| 已核对 | `data/subscription_auth.bak` | Windows metadata replace 的回滚文件,可能仍含旧明文 credential | 条件恢复/高敏感 | 先按旧 owner 的事务规则判断 canonical 缺失时是否恢复,再开始跨目录迁移;canonical 已存在时不作为第二份账户合并。遗留 backup 必须按 secret 文件保护,不能普通归档或输出内容 | -| 已核对 | `data/subscription_auth.lock`、`subscription_auth--refresh.lock`、`*.tmp` | metadata transaction/refresh lease 和写入暂存 | 不迁移 | 新目录重新创建锁;旧锁 inode/PID 生命周期无效。临时文件只保留在旧根供诊断,不导入 active store | -| 已核对 | WebSearch `credentials.key` + `credentials.json` | Web 搜索凭据 | P0 | key 与密文成组迁移,校验后原子提交 | -| 已核对 | `.mcp_oauth_vault.key` + `mcp_oauth_vault.json` | MCP OAuth 凭据 | P0 | key 与 vault 成组迁移 | -| 已核对 | `review-platform-tokens.json` | GitLab/GitCode token 明文 JSON store | P0 | 按高敏感明文数据迁移,限制文件权限并原子写入;GitHub token 由 `gh` 管理,不在此文件 | -| 已核对 | `config/app.json` 内 API key | 模型/语音等服务凭据 | P0 | 随结构化配置迁移,迁移日志不得输出值 | -| 已核对 | Windows/Linux keyring service `openbitfun.bitfun.miniapp-market.v1`,entry `github-oauth` | 更名前 MiniApp Market GitHub OAuth 的 access/refresh token 及各自过期时间 | P0/高敏感 | 读取完整旧 payload、反序列化并校验后写入新 service `openbitfun.miniapp-market.v1` 的同名 entry。目标已有完整凭据时保留目标,不逐字段拼接;任何日志不得输出 token | -| 已核对 | macOS `data/.market_credentials_vault.key` + `data/market_credentials_vault.json` | 同一 MiniApp Market OAuth payload 的文件凭据 vault | P0/高敏感 | 旧文件实际位于旧产品 config root 的 `data/`。key 与密文 vault 必须成组读取,再通过新 owner 写入目标;不能只复制其中一个,也不能覆盖已有目标 vault | - -### 4.6 未识别文件、历史目录与手工副本 - -迁移器必须对旧根做一次“已知项减法”盘点:不能因为某个路径没有当前代码 owner 就静默删除,也不能把未知文件直接放进新活动配置目录。本机旧目录的只读名称检查发现了下表中的实例;没有读取文件内容。 - -| 状态 | 相对路径/模式 | 观察与作用判断 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对目录元数据 | 根部未识别的普通文件(本机存在一个 HTML 和一个 PDF) | 很可能是用户或工具误放到产品根的文件;不是当前 active store | P0(保全) | 保留在旧根,或复制到新根之外的 `legacy-unclassified` 只读档案;在迁移报告中逐项列出相对路径。不得丢弃,也不得自动打开或解释内容 | -| 已核对目录元数据 | `config/app copy.json`、`config/app user copy.json` 等手工副本 | 非当前配置 owner 使用的正式文件,可能含用户调试副本和 API key | P0/P1(档案) | 不导入为 `config/app.json`,不参与“新旧配置谁覆盖谁”的自动裁决;作为敏感 legacy archive 保留并在报告中提示用户手工处理 | -| 已核对目录元数据 | `data/backups/*_workspace_data.json` | 旧 workspace registry 的时间戳备份;当前 active owner 只使用 `data/workspace_data.json` | P1(档案) | 保留原始旧格式。不能直接放入当前恢复集合;只有用户选择恢复某一份时,才通过与 active registry 相同的 schema-aware 转换器导入 | -| 已核对目录元数据 | 根部 `backups/` | 本机实例为空,当前代码未找到该根级目录的活动 owner | 不迁移;非空则档案 | 空目录不创建;若其他安装中非空,按未知文件保留并报告,不与 `config/backups/` 或 `data/backups/` 混合 | -| 已核对目录元数据 | `agents/templates/` | 本机实例为空,当前 checkout 未找到活动 owner | 不迁移;非空则 P0/P1 档案 | 空目录不创建;非空内容先作为用户 Agent 模板候选保全,待格式识别后再由 Agent store 显式导入 | -| 已核对目录元数据 | `data/history/`、`data/snippets/`、`data/templates/`、根部 `workspaces/` | 本机实例均为空,当前 checkout 未找到活动 owner | 不迁移;非空则档案 | 空目录不创建;其他用户机器上若非空,不能据本机样本推断其可删除,应列入 `legacy-unclassified` | -| 已核对目录元数据 | `data/lsp-plugins/` | 本机实例为空;LSP 系统已在 `69bf1c270` 退休 | 不迁移到 active store;非空则档案 | 不恢复为当前产品能力。非空的插件包或用户配置保留在 legacy archive,避免升级过程替用户删除内容 | -| 部分核对 | `data/telemetry/uid` | 本机存在 36-byte 文件;当前 checkout 和更名前父提交均未找到该路径的活动 owner,不能确认是否仍有服务端身份语义 | P2/档案,不导入 active store | 默认原地保留或归档,不把旧 UID 注入当前 telemetry/设备身份。若后续找到历史 owner 或合规保留要求,再增加专用转换;它不能替代 `device_identity.json` | - -实现时应维护显式的“已消费路径集合”。迁移结束后,对旧根剩余项只做名称、类型、大小和错误类别级报告;不要通过扩展名猜测并导入,也不要读取未知文件来生成日志摘要。 - -## 5. `~/.bitfun` 深入清单 - -### 5.1 顶层数据 - -| 状态 | 旧相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `projects/` | 本地工作区的运行时数据 | P0/P1 | 按工作区逐项迁移,详见 5.2 | -| 已核对 | `remote_ssh/` | 远程 SSH 工作区在本机的运行时镜像 | P0/P1 | 保持 `/` 相对结构,不能用 Windows path 语义重新解释 | -| 已核对 | `personal_assistant/` | 助理工作空间及多 workspace 变体 | P0 | 迁移 `workspace`、`workspace-`;同时发现更老的顶层 `workspace` / `workspace-*` | -| 已核对 | `memories/` | 记忆生成、归并和工作文件 | P0 | 整体迁移;它与 `%APPDATA%/bitfun/data/memories/memories.sqlite` 是不同数据域,均需保留 | -| 已核对 | `runtime-events/` | 每 Session 一份 JSONL 的在途流事件,用于崩溃/重启后恢复尚未完整写入 Session 的 Turn | P0 | 迁移能与已迁移 Session 关联的文件;首版不要主动删除无法关联或疑似陈旧文件 | -| 已核对 | `device_identity.json` | Remote Connect/设备身份 | P0 | 必须保留有效的 32 位十六进制 `device_id`;该 ID 可能已被账户登录流程改写为服务端绑定 ID,不能依赖主机名/MAC 重新生成 | -| 已核对 | `dispatch/` | Detached Dispatch 的 target job、controller outbound、Git 交付和崩溃恢复状态 | 分项判断 | 详见 5.4;活动任务和未完成同步必须保留,锁/PID 不迁移 | -| 已核对 | `worktrees/` | 产品管理的 Git worktree 实体根 | P0/P1 | 详见 5.5;默认不搬实体,迁移项目运行时内的注册表并保留旧绝对路径 | -| 已核对 | `relay-deploy/`、`relay-src/`、`docker-config*` | Relay 部署选择、源码/自定义和 Docker client config | 分项判断 | 详见 5.6 | -| 已核对 | `account_session.enc` + `account_session.key` | Remote Connect 登录 token、user ID、账户 master key、relay URL 和可选 device ID | P0 | 直接复制后新代码无法解密。旧 v2 key 是 `SHA256(legacy-machine-key + "\|BitFun::session_store::v2\|" + local-secret)`,更老 v1 是 machine-bound `BitFun::session_store::v1`;迁移器依次尝试旧格式,校验 payload 后以当前 `openbitfun::session_store::v1` 域重新加密。目标已有不同账户/relay 时不覆盖 | -| 已核对 | `account_hint.json` | 登录表单预填的 username + relay URL,不含 password/master key | P1 | 结构化复制;目标非空时保留目标。它不是登录凭据,不能在 account session 解密失败时制造“已登录”状态 | -| 已核对 | `account_sync/.json` | Session 云同步 pull cursor 和每 Session 已上传内容 hash | P1 | 仅在已确认是同一 user + relay 账户时合并:`last_session_since` 取较大值,`uploaded_hashes` 做并集且目标同 ID 值优先。损坏文件留存并报告,不能按当前 loader 的 default 行为静默归零 | -| 已核对 | `account_sync/.settings.json` | 设置同步的 `(version, hash)` 游标 | P1 | 只在同一 user + relay 下合并,并把 version/hash 当成不可拆分的一对;选择较高 version 对应的整对值。`*.tmp` 不迁移 | -| 已核对 | `remote_connect_persistence.json` | Feishu/Telegram/Weixin bot 连接、明文 bot secret、聊天工作区/助理/Session 选择、配对状态、表单值和 verbose 偏好 | P0 | 按高敏感 owner-only JSON store 迁移;旧 bare-string `current_workspace` 通过当前兼容反序列化升级。按 `bot_type` 合并且目标 connection 优先,旧值只填目标空缺;解析失败不得像当前普通 loader 那样降为默认空数据 | -| 已核对 | `bot_connections.json` | `remote_connect_persistence.json` 之前的同结构 bot store | P0(条件) | 仅当旧 canonical 文件不存在且不存在 `remote_connect_persistence.json.bak` 时作为来源;这是更名前 loader 的既有 fail-closed 顺序。成功导入后写当前 canonical store,旧文件保持只读 | -| 已核对 | `remote_connect_persistence.json.bak`、写入临时文件 | Windows replace 事务的回滚状态 | 不自动迁移为 active data | 若 canonical 缺失而 `.bak` 存在,表示可能停在清除/替换中;禁止回退读取 `bot_connections.json` 或擅自恢复旧账户上下文。保留并报告为需人工/owner 恢复的事务异常 | -| 已核对 | `weixin/_get_updates_buf.txt` | Weixin long-poll 增量游标 | P1(活动 bot) | 对 `remote_connect_persistence.json` 引用的 bot account ID 迁移,避免重复消费或错过恢复边界;目标已有值时由 Weixin owner 比较/验证,不按字符串大小猜测新旧 | -| 已核对 | `weixin/_context_tokens.json` | peer ID → Weixin context token;没有 token 时在下一条入站消息前不能正常回复/发送 typing | P0/P1(活动 bot) | 作为敏感 token map 迁移,按 peer ID 合并且目标值优先,文件权限收紧;不得记录 peer/token。无对应 bot account 的孤立文件保留为 legacy archive | - -`BotChatState` 的 pending action、过期时间、delegated account token/master key、活动远端设备对象等字段带 `serde(skip)`,本来就只存在于进程内,不属于迁移对象。持久化的 `account_remote_context` 标志则必须保留,因为它防止重启后把远端路径/Session 错当成本机上下文。 - -### 5.2 工作区运行时目录:`projects/` 与 `remote_ssh/` - -每个工作区不能只迁 `turns/`。一个完整 Session 可能包含 `metadata.json`、`state.json`、`turns/`、`turn-catalog.json`、`prompt_cache.json`、`token-anchors.json`、`snapshots/`、`artifacts/`、`tool-results/` 和 `evidence-ledger.json`。 - -| 状态 | 工作区内相对项 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `sessions/` | 会话 metadata、turn、状态、缓存重放信息、产物和证据 | P0 | 以完整会话为迁移单元,保留内部 ID 和相对结构;排除会话内 `request-traces/` 与运行锁 | -| 已核对 | `plans/` | 会话/工作流计划 | P1 | 随工作区迁移 | -| 已核对 | `snapshots/` | 工作区或会话快照 | P1 | 随工作区迁移;保持 Session/Turn 引用关系 | -| 已核对 | `config/isolation_status.json` | 工作区隔离状态 | P1 | 谨慎迁移;目标路径或 worktree 失效时显式降级,不能制造“已隔离”的假状态 | -| 已核对 | 项目级持久化 store(如 `hook-imports`) | 项目范围的集成配置和信任状态 | P0/P1 | 按 owning store 迁移和校验 | -| 已核对 | `request-traces/` | 请求诊断 trace | 不迁移 | 诊断数据,不属于会话事实 | -| 已核对 | `.session-write-locks/`、`locks/`、`*.lock` | 进程互斥和写锁 | 不迁移 | 新进程重新建立 | -| 已核对 | `product-search-v1.sqlite`、`search/` | 可重建搜索 sidecar/index | 不迁移 | 让新版本重建,避免旧 schema 或绝对路径污染 | -| 已核对 | 空的旧布局 `checkpoints/`、`diffs/`、`local/` | 历史布局遗留 | 不迁移 | 若实际检测到非空且代码 owner 不明,应保留在 legacy archive 并报告,不直接丢弃 | -| 部分核对 | 旧 `memory/` | 旧工作区级记忆布局 | 待定 | 不主动删除;先做版本识别,再导入当前记忆 owner 或留作 legacy archive | - -### 5.3 任意工作区内的 `.bitfun/` - -不能扫描整块磁盘来寻找工作区。应从 `workspace_data.json`、助理工作空间、远程工作区注册记录和 dispatch/worktree 注册表枚举已知工作区,再检查其根部的旧产品目录。 - -| 状态 | `.bitfun/` 内相对项 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `config/agent_profiles.json`、`tool_permissions.json`、`mode_skills.json`、`agent_subagents.json`、`hooks.json` | 项目级 Agent、权限、skill、subagent、hook 配置 | P0 | 迁移到工作区 `.openbitfun/config`,必要时执行各配置 schema 转换 | -| 已核对 | `agents/`、`rules/`、`plugins/`、可能的 `skills/` | 用户编写或安装的项目扩展内容 | P0 | 保持文件和目录结构;不得被新版本默认内容覆盖 | -| 已核对 | `search/flashgrep-index` | 工作区搜索索引 | 不迁移 | 可重建 | -| 已核对 | `computer_use_debug/` | Computer Use screenshot/OCR 调试图像;默认助理工作空间下实际形成嵌套的 `workspace/.bitfun/computer_use_debug/` | 不迁移/敏感诊断 | 新版本不需要这些 JPEG 恢复任何运行状态;文件名还可能带 OCR 查询片段。源目录保持只读或由用户另行归档,迁移报告只记录目录级数量/大小,不输出文件名和图像内容 | -| 已核对 | `bin/`、`tmp/` | 下载/运行时工具、临时文件 | 不迁移 | 新版本重建或按需重新下载 | - -### 5.4 Detached Dispatch - -同一个 `~/.bitfun/dispatch` 根下有两种角色的数据: - -- target 侧由 CLI `DispatchStore` 持有真正的 job、事件流、permission/message/turn mailbox 和目标 Git checkout; -- controller 侧 `outbound/` 只是提交到其他进程后的 durable observer 索引、恢复 journal 和传输产物。 - -#### 5.4.1 Target 侧 - -| 状态 | 相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `jobs//job.json` | 提交请求、protocol version、intent hash、创建时间、标题;也是初始化完成的 commit marker | P0 | 与该 job 的 state/events/mailbox 成组导入;检查旧 `productId`/`dataNamespace` 和协议版本,不能仅复制 | -| 已核对 | `jobs//state` | queued/running/terminal、turn、取消和错误状态 | P0 | 与 job 原子迁移;旧 `running` 不能凭 PID 直接恢复,应由 owner 的 crash recovery 判定 | -| 已核对 | `events.ndjson` + `events.meta.json` | 有界追加事件流、cursor base、截断/省略事实 | P0 | 成组迁移,保持 byte cursor 语义;不能重排或只复制事件文件 | -| 已核对 | `permissions/{pending,answers,resolved}/` | 远程可回答权限 mailbox | P0 | 完整迁移;pending 权限是远程解除阻塞所必需的数据 | -| 已核对 | `messages/{pending,consumed}/`、`turns/{pending,consumed}/` | 在途 steering 消息和后续 Turn 的幂等 mailbox | P0 | 完整迁移并保留 message/turn ID | -| 已核对 | `job.pid`、`preparing`、`.lock`、`.events.lock`、`.spawn.lock`、`.worker.lock` | 旧进程 PID、spawn/worker/写入互斥和中间标志 | 不迁移 | 升级后进程身份和锁均失效;让 current owner 重建/恢复 | -| 已核对 | `.retention-gc`、`.retention-gc.lock`、`workspaces/.*.lock`、`repos/.*.lock` | GC 时间戳和操作锁 | 不迁移 | 新进程重新调度 GC 并建立锁 | -| 已核对 | `workspaces//provision.json` | job 与 repoKey、目标 checkout、bundle 状态之间的 durable binding | P0(活动/待同步 job) | 必须和 job、repo、worktree 一致处理;其中绝对路径不能在复制后单独替换 | -| 部分核对 | `workspaces//` 其他 bundle/upload/sync 文件 | 传输断点和同步中的产物 | P0/P1(仅未完成操作) | 仅对活动 preparation/upload/sync 保留;已完成 job 的纯 staging 可由 owner 清理 | -| 已核对 | `repos//` | target 共享 bare repository;虽称 cache,但可能仍承载活动 job 或尚未拉回 controller 的提交 | P0/P1 | 活动/近期 job 引用时不能丢;不要脱离 `worktrees` 单独搬移 | -| 已核对 | `worktrees//-` | target 每 job checkout,可能含尚未同步回 controller 的用户工作结果 | P0/P1 | 不应视作缓存。优先保留旧根原位并兼容引用;若必须迁移,使用 Git-aware 迁移并修复 bare repo/worktree 双向 metadata | - -target terminal job、worktree 和事件目前按 30 天保留,共享 bare repo 也有独立的 30 天 last-used 保留策略。品牌迁移不能把“会被 retention 清理”误解成“升级时可直接丢弃”:近期 terminal job 仍需要被观察和同步结果。 - -#### 5.4.2 Controller `outbound/` - -| 状态 | 相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `outbound/.json` | durable observer 索引;保存 target/session、cursor、状态、源工作区,以及 baseline worktree ID/path/claim | P0 | 结构化迁移;绝对路径和 worktree claim 必须与 5.5 的决定一致,不能整目录覆盖 | -| 已核对 | `outbound/.preparations/.json` | 提交确认前的 owner-only crash journal 和 setup audit | P0(未完成提交) | 保留未过期/仍有关联的记录;先恢复或核对 baseline claim,再进入正常 reconciliation | -| 已核对 | `outbound/.preparations/.run` 等锁 | preparation 运行锁 | 不迁移 | 新进程重新建立 | -| 已核对 | `outbound/.transcripts/.json` | renderer observer transcript 与 cursor/completeness 配对缓存 | P1 | 建议迁移。它可从 target 重放,但 target 事件可能已截断;丢失后不一定能恢复完整可见历史 | -| 已核对 | `outbound/.bundles/` | controller 上传给 target 的 base Git bundle staging | P1(未完成上传) | 只为未完成 preparation/upload 保留;能从仍存在的 baseline 安全重建时可丢弃 | -| 已核对 | `outbound/.results/` | 从 target 拉回、尚未成功 fetch/fast-forward 的 result bundle 及摘要 | P0(未完成同步) | 在 baseline 已确认接收前必须保留;成功接收后属于可删除传输产物 | - -#### 5.4.3 Dispatch 分支名 - -品牌提交把默认 `app.worktrees.branchPrefix` 从 `bitfun/` 改为 `openbitfun/`,因此新建 dispatch baseline 的默认分支也从 `bitfun/dispatch/` 变成 `openbitfun/dispatch/`。这里的分支名不是展示文案:它同时存在于实际 Git ref、managed worktree 注册表、controller `outbound/.json`、preparation journal,以及 target 的 provision/job 状态中。 - -迁移时应区分“未来默认值”和“既有实体”: - -1. 只有 `config/app.json` 中的 `app.worktrees.branchPrefix` **精确等于旧默认值** `bitfun/` 时,才迁为 `openbitfun/`;用户自定义前缀必须原样保留。 -2. 已存在或仍活动的 `bitfun/dispatch/*` 分支及其所有引用默认保持原名。新代码应按记录中的真实分支操作,而不能根据当前默认值重新推导。 -3. 不能只改 JSON 中的 `branch`。那会让记录与实际 ref 分离,并可能使 baseline guard、result fetch/fast-forward 或 retention claim 失效。 -4. 如果产品明确要求重命名既有分支,必须作为 Git-aware 原子事务:确认 checkout/ref/dirty 状态,重命名真实分支并同步 worktree registry、outbound、preparation、target provision/job 等全部引用;任一步失败都保留旧分支与可恢复 journal。 - -### 5.5 普通 managed worktree - -普通 worktree 的实体默认位于 `~/.bitfun/worktrees//-`,但注册表 `worktrees.json` 位于对应项目的运行时 `config` 目录中。注册表保存 `projectWorkspacePath`、每个 worktree 的绝对 `path`、branch/base/lifecycle、幂等 receipt,以及 `dispatch:` retention claim。 - -Git 的 common dir `worktrees/*` admin metadata 和 linked checkout 内的 `.git` 文件也包含路径关系。因此建议: - -1. **迁移项目 runtime 中的 `worktrees.json`,但默认不搬 `~/.bitfun/worktrees` 实体。** -2. 新注册表继续引用旧实体的绝对路径;新建 worktree 使用新的 `~/.openbitfun/worktrees` 根。 -3. 保留并验证 dispatch claim,避免升级后自动清掉仍作为 dispatch baseline 的 worktree。 -4. 若产品决定实体也必须改路径,应走单独 Git-aware 事务:确认 clean/dirty/branch/claim 状态,使用 Git worktree 操作或 repair 更新两侧 metadata,再更新工作区索引、注册表和所有 outbound record。普通文件复制后字符串替换不可接受。 -5. 用户在 `app.worktrees.root_path` 中显式配置的自定义绝对根不应改写;只有等价于旧产品默认值的配置才可按迁移策略处理。 - -### 5.6 Relay 自部署与远端 Docker 数据 - -这一组数据位于执行 Relay 部署的 SSH 目标机,不会因为本机 Desktop 目录迁移而自动迁走。所有运行过旧 CLI/Relay 自部署的主机都要独立发现和处理。 - -| 状态 | 旧位置/对象 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `~/.bitfun/relay-deploy/relay.port` | Relay 监听端口选择 | P1 | 迁移到新目录,避免升级后回到默认端口导致健康检查或端口冲突 | -| 已核对 | `~/.bitfun/relay-deploy/relay.mirror-mode` | 镜像路线选择 | P2 | 可迁移用户选择;不存在则重新 auto 探测 | -| 已核对 | `relay-deploy/*.sh`、`*.pid`、`*.driver.pid`、`*.preparing`、`*.log` | 生成脚本、运行 PID/准备标志和部署日志 | 不迁移 | 升级不得接管旧 PID;检测到仍在运行的部署任务时应停止迁移并要求其结束/取消,而不是复制状态 | -| 已核对 | `relay-deploy/import-*.json` | 一次性账户导入文件,正常路径会立即删除 | 不迁移 | 若遗留,按高敏感失败产物告警并留在旧目录,不复制到新活动目录 | -| 已核对 | `~/.bitfun/docker-config`、`docker-config-` | Relay 自部署专用 Docker client config,可能含 registry auth 和用户 Docker 设置 | P0/P1 | 迁移 `config.json` 及未知用户文件,保留 0700/所有权;不要输出 auth。多个 uid 目录分别处理 | -| 部分核对 | `~/.bitfun/relay-src` | source-mode Relay 部署 checkout、`.env` 及可能的用户修改 | P1 | 不能整体当缓存。`.env`、Caddy/部署自定义和 Git dirty 文件必须保留;纯可验证 clean checkout 可重新 clone | -| 已核对 | Docker container `bitfun-relay` → `openbitfun-relay` | 正在运行的旧 Relay 服务 | P0(运行迁移) | 新部署前受控停止/暂存旧容器,验证新容器健康后再移除;否则旧容器会占用端口,新部署也看不到它 | -| 已核对 | Docker volume `relay-server_relay-db` | Relay 账户、设备、会话/路由等数据库所在 volume | P0 | volume 名未变,但容器内 DB 文件从 `/app/data/bitfun_relay.db` 改为 `openbitfun_relay.db`;必须在旧容器停止/SQLite 一致性确认后迁移 DB,不能让新容器创建空库 | -| 已核对 | Docker volume `relay-server_room-web` | Remote room Web 资产和 OpenBitFun Pages 数据 | P0/P1 | volume 名和 mount path 未变,应原地保留;不得作为镜像缓存删除 | -| 已核对 | 旧 Relay image 与 brand label | 可重新拉取的容器镜像/标记 | 不迁移 | 新 Relay 验证健康后再由维护流程清理旧镜像 | - -若用户通过 Compose source mode 部署,volume 的实际前缀由 Compose project 决定,不能假定总是 `relay-server_*`;迁移器应从旧容器 inspect 的 mount 列表发现真实 volume。 - -### 5.7 CLI daemon 与远端安装状态 - -| 状态 | 旧位置/对象 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `~/.bitfun/cli_daemon.pid` | CLI account-device daemon 的进程 PID | 不迁移 | PID 只对旧进程有效;受控停止旧 daemon 后由新 daemon 重建 | -| 已核对 | Linux `$XDG_CONFIG_HOME/systemd/user/bitfun-cli-daemon.service` | 旧 CLI daemon 自启动单元 | P0(系统集成) | stop/disable 旧 unit,安装并启动 `openbitfun-cli-daemon.service`;不能简单复制,因为 ExecStart 和 unit 名均变化 | -| 已核对 | macOS `~/Library/LaunchAgents/com.bitfun.cli.daemon.plist` | 旧 CLI daemon LaunchAgent | P0(系统集成) | bootstrap/bootout 有序切换到 `com.openbitfun.cli.daemon`,并更新可执行文件路径 | -| 已核对 | `~/.bitfun/dispatch/install/` | SSH 自动安装 CLI 的 archive、脚本和 staging | 不迁移 | 安装传输产物可由当前签名 release 重新供应 | -| 已核对 | 旧 `bitfun` CLI 可执行文件/软链接 | 旧 daemon、交互命令和自升级入口 | P0(升级入口),不属于用户数据 | 当前 Linux self-update 的 `install_archive` 明确要求当前文件名为 `openbitfun`,并从 archive 取同名 binary;以 `bitfun` 文件名运行会被拒绝。品牌切换必须由外部 bootstrap/package manager 或专门的旧版 bridge 安装新 binary,再切换 service/PATH;验证新 CLI 与 daemon 后再决定保留 `bitfun` compatibility shim 或清理。Windows/macOS 当前 self-update 本来不支持,分别走 installer/package manager | - -旧 daemon 与新 daemon 不能长期并行:同机进程共享 `device_id`,Remote Connect 按该 ID 路由,后连接者会抢占路由。升级顺序必须是“停旧服务 → 迁移身份/账户 session → 安装新服务 → 验证在线”。 - -### 5.8 持久 metadata 与跨版本 wire 保留键 - -品牌提交还改名了若干双下划线保留键。它们不一定各自形成独立文件,但会进入持久 Turn/缓存,或跨 Desktop、CLI、Runtime、Peer 的事件 envelope。当前 reader 都只读取新名称,因此必须在兼容层双读,而不能指望目录复制自动修复。 - -| 状态 | 旧键 → 新键 | 出现位置与作用 | 迁移/兼容结论 | -|---|---|---|---| -| 已核对 | `__bitfunOptimisticDispatchJobId` → `__openbitfunOptimisticDispatchJobId` | `DialogTurn.userMessage.metadata` 中的 optimistic-turn adoption 标记;会随 Session Turn、dispatch transcript cache 等完整 Turn 持久化 | P0(含仍 pending 的 Turn)。读取与 strip 阶段同时接受旧/新键,写回时规范化为新键。当前只读新键,旧 optimistic Turn 可能无法被 target 的正式 Turn 接管,造成可见重复 Turn | -| 已核对 | `__bitfunRuntimeStreamId`、`__bitfunRuntimeEventCursor` → 对应 `__openbitfun*` | Runtime 投递给 Desktop/Web/Peer 的事件 payload 游标,用于检测跨进程 stream 与事件 gap | wire 兼容 P0。接收端在支持旧 peer/runtime 的窗口内双读并同时剥离两组键;发送端应按协商能力选择名称或在过渡期双写。否则旧键会被当作无游标事件,gap fencing/replay 语义失效 | -| 已核对 | `__bitfunSourceDeviceId`、`__bitfunSourcePayload` → 对应 `__openbitfun*` | Desktop 给 peer-originated event 加的来源设备和非对象 payload wrapper;通常只存在于 Remote Connect/Peer 事件 envelope | wire 兼容 P0,不作为独立磁盘 store 搬迁。混合版本双读/双剥离;否则 peer 事件可能被误判为本地未标记事件,或把 wrapper 本身交给业务消费者,造成跨设备事件串线 | -| 已核对 | Runtime identity hash domain `bitfun-agent-runtime-instance-v2` → `openbitfun-agent-runtime-instance-v2` | Shared Runtime 根据产品、channel、用户数据根、protocol、workspace 计算的实例 hash;hash 命名 discovery/lock,并派生 named pipe/UDS 名 | 不迁移。它是活进程 ownership/discovery 边界,不是 Session identity;新版本应创建新实例。升级器需先停旧 Shared Runtime,不能为了“复用”而伪造新 hash 指向旧 endpoint | -| 已核对 | CLI stdio Host identity `bitfun-cli-server-host` → `openbitfun-cli-server-host` | `openbitfun server` 注入的进程内 `AppServerHostPolicy` identity | 当前无独立落盘对象,不迁移。它不是 `device_identity.json`,也不应据此改 Session/workspace ID;若外围客户端另有持久 host allowlist,那属于该客户端自己的跨版本兼容数据,需同时接受旧/新 identity 后再规范化 | -| 已核对 | `bitfun://runtime/...`、`bitfun://current-session/...` → `openbitfun://...` | tool path、tool result、compression transcript、session reference 等会把逻辑路径写入 Session Turn 和相关持久记录 | P0(历史记录仍需恢复/解析时)。不能对 Session JSON 全局替换;path parser/resolver 在兼容期双读旧 scheme,完成 workspace/session 边界和 traversal 校验后只在运行时 canonicalize。新记录只写新 scheme;当前 `tool-contracts` parser 仅声明新前缀 | -| 已核对 | `bitfun://payloads/...`、`bitfun://artifacts/...`、`bitfun://logs/...` → `openbitfun://...` | adapter/runtime diagnostics 中的 payload、artifact 和 log URI,可能随 tool/plugin 结果持久化 | 保留原始历史 URI用于展示与审计;需要 dereference 的 owner 双读并映射到已验证对象,不按字符串前缀直接拼本地路径。新记录只写新 scheme | -| 已核对 | `bitfun://pair?...` → `openbitfun://pair?...` | Remote Connect 二维码/深链,可能被用户保存、转发,或在混合版本手机与 Desktop 间传递 | wire/deep-link 兼容 P0,不属于磁盘目录搬迁。新移动端在兼容窗口内接受旧链接并按字段校验;不能只接受新 scheme 后要求用户重新生成仍有效的旧链接 | - -`~/.bitfun/runtime-events/*.jsonl` 不需要为上述游标键做全局文本替换:其落盘 record 的稳定字段是 `streamId`、`cursor`、`event`。该目录仍按 5.1 作为 Session 恢复数据迁移,保留原始 JSONL 顺序与字段即可。保留键兼容应放在事件 envelope owner,而不是数据目录迁移器中。 - -### 5.9 持久环境变量与启动配置 - -进程环境变量本身不是目录中的文件,但用户或部署者会把它们持久化在 Windows `HKCU\Environment`/系统环境、PowerShell profile、`.bashrc`/`.zshrc`/`.profile`、systemd unit/drop-in、LaunchAgent plist、Task Scheduler/服务配置、Docker Compose `.env` 或部署 env 文件中。当前运行时大多只读 `OPENBITFUN_*`,因此它们属于升级发现与配置迁移范围。 - -| 状态 | 旧变量 → 新变量 | 作用与优先级 | 迁移方式与注意事项 | -|---|---|---|---| -| 已核对 | `BITFUN_HOME` → `OPENBITFUN_HOME`;`BITFUN_USER_ROOT` → `OPENBITFUN_USER_ROOT`;`BITFUN_DEVICE_IDENTITY_PATH` → `OPENBITFUN_DEVICE_IDENTITY_PATH` | 决定产品 Home、用户数据根和设备身份文件的真实位置;P0 | migration bootstrap 必须在初始化新 `PathManager`、CLI config、Remote Connect store 前读取。新变量存在时始终优先;仅在目标变量缺失时把有效旧路径作为源定位/精确迁移候选,不能先使用新默认根再寻找旧数据 | -| 已核对 | `BITFUN_BUN_COMMAND`、`BITFUN_OPENCODE_BUN_HOST_ENTRY` → 对应 `OPENBITFUN_*` | Plugin Host/Server 的 Bun 命令和 host entry 覆盖;P1 | 仅在已识别的产品启动配置 owner 中改 key 并保留原值;目标变量优先。路径或命令需重新验证可执行性,不全局替换 shell/profile 文本 | -| 已核对 | `BITFUN_MINIAPP_MARKET_API_URL`、`BITFUN_APPEARANCE_MARKET_API_URL` → 对应 `OPENBITFUN_*` | 自定义 Market endpoint;P1 | 精确改名并验证 URL;目标值优先。不能把默认生产 URL 推断成用户显式配置,也不能因旧 endpoint 离线而删除它 | -| 已核对 | `BITFUN_CLI_DISABLE_AUTO_UPDATE`、`BITFUN_LOG_DIR` → 对应 `OPENBITFUN_*` | CLI 自动更新策略和自定义日志目录;P1/P2 | 在产品管理的 service/env 配置中迁移;布尔开关按存在语义保留,自定义路径保持原意。日志本身仍不迁移,且旧路径不能在未校验时被递归搬运 | -| 已核对 | `BITFUN_MIRROR` 及同组 mirror/Docker/release route 变量 → `OPENBITFUN_*` | Relay/self-host 的用户或 operator 部署选择;条件 P0/P1 | 若位于持久 `.env`、systemd/service 配置或产品管理的 deploy state,按 owner 的明确字段逐项迁移;自动探测结果和单次脚本变量不迁移。signing secret、release credential 等由 operator/secret manager 改名,Desktop 迁移器不得读取 | -| 已核对 | `BITFUN_MODELS_DEV_PATH/URL`、`BITFUN_OPEN_DEVTOOLS`、`BITFUN_BROWSER_VIDEO_DECODER_MODE`、`BITFUN_PERF_TRACE`、`BITFUN_WEBDRIVER_*` → 对应 `OPENBITFUN_*` | 开发、诊断、WebDriver 覆盖 | 默认不迁移;开发者若仍需要应在自己的启动配置中显式改名。迁移器不应把诊断行为带入普通生产首启 | -| 已核对 | `BITFUN_CLI_BACKGROUND_UPDATE` 以及 test child/PID/lock/ready/E2E 变量 → 对应 `OPENBITFUN_*` | 子进程协议、互斥或测试瞬时状态 | 不迁移。由新进程重新生成;把旧值带入新进程可能误判后台模式、锁或测试环境 | -| 已核对 | updater endpoint、release/signing key、Android signing、package/release channel 等 build/CI-only 变量 | 构建与发布基础设施 | 不属于用户数据迁移。由 CI/secret owner 独立改名和轮转,客户端不得发现、复制或记录这些值 | - -迁移器只能自动修改自己拥有且有结构定义的注册表值、service/drop-in、plist 或 `.env`。对任意 shell/profile/用户文件只报告“发现旧变量名及文件位置”,不得做全局文本替换;尤其不能在报告中记录变量值。 - -### 5.10 Hosted MiniApp/Skin Market 服务数据 - -这组是自部署/生产运维数据,不在 Desktop 用户根中。品牌提交把两个服务的系统路径、container/service 名和 artifact contract 一并改名;旧实例与新实例不能同时写同一数据库或 artifact tree。 - -| 状态 | 旧位置/对象 → 新位置/对象 | 作用与优先级 | 迁移方式与注意事项 | -|---|---|---|---| -| 已核对 | `/srv/bitfun-miniapp-market/data/market.sqlite` → `/srv/openbitfun-miniapp-market/data/market.sqlite` | MiniApp Market 账户、投稿、release 等权威数据库;P0 | 停止旧服务,用 SQLite backup 生成 offline copy,运行结构迁移并做 `integrity_check`,再切换新服务。不能复制 live DB 或让新服务先创建空库 | -| 已核对 | `/srv/bitfun-miniapp-market/artifacts` → `/srv/openbitfun-miniapp-market/artifacts` | 用户上传的 MiniApp package、截图等 artifact;P0 | 与 DB 引用闭包一起复制到 offline staging,校验引用、hash、size 和权限后原子切换;不能把 artifact 当构建缓存 | -| 已核对 | `/srv/bitfun-skin-market/data/market.sqlite` + `artifacts/` → `/srv/openbitfun-skin-market/...` | Skin 投稿/release DB、appearance package 和 preview;P0 | DB 与 artifact tree 必须成组离线迁移。只移动目录仍会留下旧 archive/schema/hash,需执行下述专用转换 | -| 已核对 | 两个旧根的 `backups/` → 新根 `backups/` | 数据库与 artifact 的 daily/weekly 恢复资产;P0/P1 | 保留原备份为不可变 legacy recovery set;不要让新 backup rotation 在验证前裁剪旧集合。进入新恢复集合前应演练完整 restore 和 schema migration | -| 已核对 | `/etc/bitfun-miniapp-market/market.env`、`/etc/bitfun-skin-market/market.env` → 对应 OpenBitFun 路径 | OAuth、session/CSRF secret 和服务配置;P0/高敏感 | 由 operator 在受控权限下复制并按字段改名,保持 `root:root`/`0600` 等权限;不得把内容写入迁移报告、普通 backup 或 Desktop 迁移器 | -| 已核对 | 旧 container、systemd service/timer、backup/restore executable 名 → OpenBitFun 对象 | 服务启动、备份调度和恢复入口;P0(系统集成) | 停旧 unit/container,安装新对象并指向已验证的新路径;验证 DB/artifact/backup 后再启用 timer。不得让新旧 timer 同时操作同一数据 | -| 已核对 | MiniApp DB JSON 中 `minBitfunVersion`/`minOpenbitfunVersion` → `minOpenBitFunVersion` | `submissions.metadata_json`、`releases.metadata_json` 的最低客户端版本 contract;P0 | 使用 `deploy/openbitfun-host/migrate-market-data-v1.py miniapp` 在 offline DB 上转换并规范为 `1.0.0`;脚本会拒绝传入声明为 live 的 DB,解析冲突时应中止而非猜测 | -| 已核对 | Skin `.bitfun-appearance`、`appearance.json` schema `bitfun.appearance` 和旧 DB 引用 → OpenBitFun contract | package 内容寻址、manifest、review bundle 与 DB 引用;P0 | 使用同一工具的 `skin` 模式重写 archive、改 schema、重算 package SHA-256/size/manifest/review-bundle hash 并同步 DB;旧 archive 移入 artifact tree 下 `migrated-bitfun-v1`。不能只改扩展名或只更新 DB | -| 已核对 | `/srv/bitfun-*-market/app` checkout | 部署源码,不是权威用户数据;clean checkout 不迁移/P2,dirty 修改可能是 operator 自定义且应作为 P1 档案 | 新路径按已记录 revision 重新部署;发现 dirty/untracked 文件时保全并报告,由 operator 审核后移植,不能用新 checkout 覆盖 | - -## 6. Windows 本机 SSH 数据 - -Desktop 和 Server 当前都以 `dirs::data_local_dir()/OpenBitFun/ssh` 初始化 `SSHConnectionManager`;品牌提交把中间目录从 `BitFun` 改为 `OpenBitFun`。 - -| 状态 | 旧目录内相对路径 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `ssh_connections.json` | 已保存连接;包括 host/port/user、认证类型、私钥/证书路径、ProxyJump、container、超时重试等,不含密码明文 | P0 | 结构化导入并合并;新增字段有 default。旧 connection ID 可能是 `ssh-@:`,当前稳定格式是无 port 的 `ssh-@` | -| 已核对 | `remote_workspace.json` | 用 `connectionId + remotePath` 标识的远程工作区恢复记录 | P0 | 旧版可能是单对象,当前只接受数组;转数组并与 connection ID 同步改名,保持 `remotePath` 的 POSIX 语义 | -| 已核对 | `known_hosts` | 产品内保存的 SSH host key/fingerprint 信任记录 | P0 | 完整迁移并按 `host:port` 合并;冲突必须显式报告,不能以新或旧值静默覆盖 | -| 已核对 | `.ssh_password_vault.key` + `ssh_password_vault.json` | 随机 AES-256-GCM key 和按 connection ID 索引的密码密文 | P0 | 两文件成组迁移。若 connection ID 改名,需要同步重键 vault entry;密文可原样保留,但必须验证 key 长度和至少一次受控解密 | -| 已核对 | 私钥/证书路径指向的外部文件 | 用户自己的 SSH key/certificate | 不属于品牌目录迁移 | 不复制外部 key;保留路径。若路径恰好位于旧 BitFun 目录内,作为异常情况提示用户或由专用规则迁移并更新引用 | - -connection ID 是四个 store 的关联键:`ssh_connections.json`、`remote_workspace.json`、密码 vault 和可能引用该连接的 workspace/dispatch 记录必须在同一个迁移事务中转换。任一凭据不可用时仍应保留 profile 和 workspace,明确提示重新输入凭据,不能把记录裁掉。 - -## 7. Tauri 与 WebView 持久化 - -WebView 数据不能整体视为运行缓存。品牌更名同时改变 Tauri identifier 和若干前端 storage key;因此即使复制旧 WebView profile,仍可能需要在前端存储层做旧 key → 新 key 的兼容读取或一次性改名。 - -| 状态 | 数据 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `%APPDATA%/com.bitfun.desktop/.window-state.json` | 窗口大小、位置、最大化等状态 | P2 | 可迁移;恢复前校验显示器和几何,避免旧屏幕布局把窗口放到不可见区域 | -| 已核对 | `webview-recovery.json` | WebView 异常恢复/重建标志 | 不迁移 | 旧版本的异常状态不应触发新版本恢复流程 | -| 已核对 | WebView Local Storage | 语言、布局宽度、模型选择、输入历史、待发送消息、dispatch 观察状态、终端 profile、更新跳过等 | P1,部分 P0 | 复制 profile 后还需迁移改名的 storage key;按 key 设定冲突策略 | -| 已核对 | IndexedDB `openbitfun-appearance` 及旧品牌对应库 | 主题/外观包 | P1 | 做数据库/对象级迁移或验证 UDF 复制可保留;用户自定义外观不可当缓存丢弃 | -| 已核对 | Network/Cookies、Local State、Profile Preferences | 嵌入页面或自动化相关登录态、浏览器 profile 密钥/偏好 | P1/可选 | 若迁移 Cookie,必须在 WebView 关闭时成组复制 `Local State`、Profile 和 Network/Cookies | -| 已核对 | `sessionStorage` | 当前 WebView 进程会话状态 | 不迁移 | 生命周期仅限当前进程 | -| 已核对 | Code Cache、Cache、GPUCache、shader/component caches、Crashpad、运行锁 | 缓存与运行时状态 | 不迁移 | 新 WebView 重建 | - -### 7.1 Local Storage 与 IndexedDB 键级清单 - -下表来自更名提交中 Web 存储 owner 的选择性比较,不包含 CSS class、DOM attribute、事件名等非持久化品牌字符串。所有 Local Storage 迁移都应在 Zustand store、输入队列和页面状态首次 hydrate 之前完成。 - -| 状态 | 旧 key/数据库 | 新 key/数据库 | 作用与优先级 | 转换与冲突策略 | -|---|---|---|---|---| -| 已核对 | `bitfun.workspace-session-view.v2` | `openbitfun.workspace-session-view.v2` | Session 列表分组、排序和过滤,P1 | 目标不存在时导入;若 payload 的 `filters.hiddenSources` 含旧枚举 `bitfun`,需改为 `openbitfun`,再走当前 store version migrate。目标已存在时保留目标偏好 | -| 已核对 | `bitfun-input-history` | `openbitfun-input-history` | 按 Session 保存的输入历史,P1 | 先走 store 内部 v1→v2 迁移,再按 Session 合并;保持“最近在前”、去重并执行每 Session 100 条上限。不能因 key 改名绕过现有 version migrate | -| 已核对 | `flowChat.pendingQueue.`;`flowChat.peerPendingQueue.v1.` | `openbitfun.flowChat.pendingQueue.v1.` | 尚未发送/等待 drain 的本地与 Peer composer queue,P0 | 这是本次提交中同时改了 key 格式的高风险项。旧 local key 补 `surfaceId=local`,旧 peer key保留元组;按 queue item ID/本地 Turn ID 合并且保留完整 composer payload。必须先写新 key 并验证,再删除新 profile 内的旧 key;源 profile仍只读 | -| 已核对 | `bitfun-dispatch-jobs-v1` | `openbitfun-dispatch-jobs-v1` | controller 的 dispatch observer Zustand 快照,P0/P1 | 与 `~/.bitfun/dispatch/outbound` 对账后导入;按 job ID 合并,backend/outbound 事实优先于陈旧 renderer projection,不得让旧快照复活已 terminal/已删除的 job | -| 已核对 | `bitfun-dispatch-dismissals-v1` | `openbitfun-dispatch-dismissals-v1` | 独立的 job/session dismissal ledger,防止陈旧 renderer 重新投影,P0 | 与目标 ledger 做集合并集并执行当前 2048 条边界;不能仅在 target key 不存在时复制,否则会丢 deletion authority | -| 已核对 | `bitfun-context-storage` | `openbitfun-context-storage` | composer 选择的非 image、非 PR 上下文,P1 | 校验 Zustand envelope;目标为空时导入。两侧均有数据时按 context ID 合并、目标顺序优先,并继续排除当前 owner 明确不持久化的 image/PR context | -| 已核对 | `bitfun-i18n-state` | `openbitfun-i18n-state` | 当前语言、fallback 和自动检测偏好,P1 | 只迁当前 locale contract 仍接受的字段和值;目标已设置时保留目标。`bitfun-language` 不是当前活动 owner | -| 已核对 | `bitfun-manual-terminal-profiles:` | `openbitfun-manual-terminal-profiles:` | 每工作区的手工终端 profile,P0/P1 | 从已知 workspace registry 枚举 key,不遍历猜测工作区;按 profile ID 合并并保持命令、cwd/env 等敏感字段,不输出内容。工作区路径若搬迁需同步重键 | -| 已核对 | `bitfun:review-platform:last-remote:` | `openbitfun:review-platform:last-remote:` | 每工作区最近选择的 Review remote,P1 | 目标缺失时复制;校验 remote ID 仍存在。工作区路径变更时同步重键,不用控制端 path 语义改写远程路径 | -| 已核对 | `bitfun:settings:acp-agents:hidden-remote-connections:v1` | `openbitfun:settings:acp-agents:hidden-remote-connections:v1` | 用户隐藏的远程 ACP connection ID 集合,P1 | 与目标集合并集;若第 6 节迁移 connection ID,必须同步改值。无效 ID 保留在报告中,不静默当作用户取消隐藏 | -| 已核对 | `bitfun.sessionUsage.export.redactPaths` | `openbitfun.sessionUsage.export.redactPaths` | Session Usage 导出时是否隐藏路径,P1(隐私偏好) | 只接受布尔语义;目标已存在时保留目标。迁移失败使用当前更保守的默认值,不把失败解释为用户明确关闭脱敏 | -| 已核对 | `bitfun.reasoning-presets.recent.v1` | `openbitfun.reasoning-presets.recent.v1` | 每模型最近选择的 reasoning preset,P2 | 按 model ID 合并,目标值优先;仅保留当前模型声明仍支持的 preset,未知值可留在 legacy report | -| 已核对 | `bitfun:remote-connect:disclaimer-agreed:v1` | `openbitfun:remote-connect:disclaimer-agreed:v1` | 远控免责声明已确认状态,P2 | 仅将有效 `true` 导入到空目标;它不是账户授权或设备身份,迁移失败不得影响 Remote Connect 数据本身 | -| 已核对 | `bitfun:update:lastDailyPromptDate`、`bitfun:update:lastPromptedLatestVersion`、`bitfun:update:skippedVersion` | 对应 `openbitfun:update:*` | 更新提示/跳过版本偏好,P2 | 三项作为一个小 store 迁移;校验日期/版本字符串,目标值优先,失败不阻塞主迁移 | -| 已核对 | `bitfun:leftPanel*`、`bitfun:rightPanel*`、`bitfun:bottomTerminalPanelLastHeight` | 对应 `openbitfun:*` | 布局尺寸/折叠偏好,P2 | 当前实际读取的是 `rightPanelLastWidth` 和 `bottomTerminalPanelLastHeight`;只迁活动项并校验有限正数。其余旧键不应因常量仍存在就自动导入 | -| 已核对 | `bitfun-appearance` IndexedDB,stores `packages`、`catalog` | `openbitfun-appearance` | 用户导入/编辑的 Appearance package 与 catalog,P1;用户创作项可视为 P0 | 不能只创建同名空库。按 package ID 读取、校验并写入目标 DB;同 ID 目标项优先,保留冲突副本/报告。需要保证 catalog 与 package 引用闭包 | -| 已核对 | `bitfun-market-theme`、`bitfun-market-locale` | `openbitfun-market-theme`、`openbitfun-market-locale` | MiniApp Market 页面主题和语言;P2 | 同 origin 页面先读新 key,目标缺失时校验旧值并写新 key。普通外部浏览器由页面自己迁移,Desktop 迁移器不扫描浏览器 profile | -| 已核对 | `bitfun-skin-market-theme`、`bitfun-skin-market-locale` | `openbitfun-skin-market-theme`、`openbitfun-skin-market-locale` | Skin Market 页面主题和语言;P2 | 与 MiniApp Market 相同;若站点换了 origin,Local Storage 不会自动跨域,需要服务端 bridge 或用户可控 export/import | -| 已核对 | `bitfun.design-lab.locale` | `openbitfun.design-lab.locale` | Design Lab 语言;P2(开发者) | 仅在 Design Lab 自己的 origin/profile 中迁移有效 locale;不导入 Desktop 主应用 profile | -| 已核对 | `bitfun.design-lab.token-drafts.v1` | `openbitfun.design-lab.token-drafts.v1` | Design Token 编辑草稿;开发者场景 P0/P1 | 在 token editor 首次 hydrate 前按草稿 identity 合并,目标草稿优先并保留冲突副本;不能因它属于开发工具而当缓存丢弃 | -| 已核对 | `bitfun-mobile-language`、`bitfun-mobile-theme` | `openbitfun-mobile-language`、`openbitfun-mobile-theme` | Mobile Web 语言和主题;P1/P2 | 在 Mobile Web bootstrap/Provider 读取前迁移当前 locale/theme contract 接受的值;目标值优先 | -| 已核对 | `bitfun.mobile.install_id`、`bitfun.mobile.user_id` | `openbitfun.mobile.install_id`、`openbitfun.mobile.user_id` | Mobile Web 稳定 install identity、已确认 user ID/表单预填和无账户模式自动重连;P0 | 两项按一个 identity 组合迁移,保持 install ID 原值;目标已有不同 install ID 时进入设备冲突流程,不能覆盖或生成新 ID 后假装完成迁移。user ID 属敏感标识,报告不记录值 | -| 已核对 | `bitfun.mobile.user_id_failure_count`、`bitfun.mobile.user_id_lock_until` | 对应 `openbitfun.mobile.*` | Mobile Web 本地配对保护失败计数与 60 秒 lockout;P0(仍生效的安全状态) | 成组读取并校验数值;仅保留尚未过期的 lockout,过期后由 owner 清除两项。不能只迁 failure count 而丢 lock deadline,也不能把迁移失败解释为成功验证 | -| 已核对 | `bitfun.mobile.last_selected_model_id` | `openbitfun.mobile.last_selected_model_id` | Mobile Web 最近模型选择;P1 | 目标缺失时导入;加载 catalog 后通过现有 normalize/resolve 逻辑验证,失效模型降级为 `auto`,但在 legacy report 中保留旧值摘要而不重写为另一个模型 | -| 已核对 | IndexedDB `bitfun-mobile-remote-cache`,stores `session-state`、`transcripts` | `openbitfun-mobile-remote-cache` | 按 account/device 保存的远端 Session/工作区列表与 transcript 离线投影;P1 | 权威数据仍在目标 Desktop/Relay,但缓存含离线可见历史。按 `accountId + deviceId` scope、Session ID 合并,保留较新的记录并遵守当前每设备数量上限;不能复制底层 IndexedDB 文件到已有目标库 | -| 已核对 | `bitfun-flow-chat-state`、`bitfun-flow-chat-global`、`bitfun-session-ids`、`bitfun-session-*` | 提交中改成 `openbitfun-*`,但当前 constructor 仍会主动清除此组 | 已退休的旧 renderer Session cache,不迁移 | 权威会话在 Rust Session store。不要把这组键导入新 profile;否则下一次启动也会被 `clearOldStorage()` 清掉 | -| 已核对 | `bitfun-*` 常量组中的 `language`、左右 panel、recent workspaces、user preferences、model configs、chat history、diff warning | 提交中有对应 `openbitfun-*` 常量 | 当前 checkout 除 `manual-terminal-profiles` 外没有活动 reader,默认不迁移 | 这些仅定义于 `shared/constants/app.ts`,不能据名称把过时 browser cache 当权威数据。若未来 owner 恢复,需单独补 schema owner 和迁移测试 | -| 已核对 | `bitfun.deepReview.skipCostConfirmation` | `openbitfun.deepReview.skipCostConfirmation` | 当前只在测试 fixture 中出现,生产代码无 reader | 不迁移。不能根据测试字符串推断存在活动偏好 store | -| 已核对 | `bitfun.flowChat.mockBackgroundCommands` | `openbitfun.flowChat.mockBackgroundCommands` | 仅开发环境 mock 开关 | 不迁移 | -| 已核对 | `wing_coder_current_model_config`、`editor-config` | 名称未变 | 当前模型选择与编辑器偏好,P1/P2 | 若采用选择性导出/导入而非复制完整 Local Storage,仍应分别保留;前者的 ID 必须在已迁移模型配置中存在,后者只合并当前接受字段 | - -`sessionStorage` 中改名的 `bitfun:flowchat:*`、startup reload、unexpected-exit/config-recovery notice、thread-goal dismiss、`pendingProjectDescription` 和 `pendingOpenSettings` 都不迁移。它们要么只用于当前 renderer 生命周期,要么是一次性导航/防重复提示状态;跨版本恢复反而可能跳过新进程应执行的动作。 - -### 7.2 WebView profile 的迁移边界 - -- **目标 UDF 尚不存在**:在 Desktop/WebView 完全退出后,将旧 UDF 中的持久子树复制到同卷 staging,再排除缓存、Crashpad、运行锁和 session-only 数据;校验后原子成为新 UDF。随后在任何页面 store hydrate 前执行 7.1 的 key/schema 迁移。 -- **目标 UDF 已存在**:不能把旧 `Local Storage/leveldb`、IndexedDB 或 Cookies 的底层文件覆盖/拼接到目标目录。应以独立 WebView2 environment 或受支持的 store 导出器只读打开旧 UDF,再通过各 owner 合并到目标;若无法安全读取,保留旧 UDF并报告,不创建“成功但数据为空”的标记。 -- **Cookie/登录态是单独承诺**:只有产品明确承诺保留嵌入页登录态时,才迁移 `Local State`、对应 Profile、Network/Cookies 和相关密钥材料;同一 Windows 用户之外不可假设密文可解。浏览器自动化 profile 仍按 4.4 独立处理,不能和 Desktop WebView UDF混用。 - -### 7.3 Desktop 开发 identifier - -`src/apps/desktop/tauri.dev.conf.json` 也从 `com.bitfun.desktop.dev` 改为 `com.openbitfun.desktop.dev`。它会形成与正式版、以及新 dev build 都不同的 Tauri app-data/WebView profile: - -| 状态 | 旧位置 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | `%APPDATA%/com.bitfun.desktop.dev` | dev build 的窗口状态和 Tauri app data | P2(开发者) | 只迁有价值的窗口偏好;排除 `webview-recovery.json`。不得导入正式 `com.openbitfun.desktop` profile | -| 已核对 | `%LOCALAPPDATA%/com.bitfun.desktop.dev/EBWebView` | dev build 的 Local Storage、IndexedDB、Cookie 与缓存 | 分项判断(开发者) | 按 7.1/7.2 同样做 store 级迁移;通常不承诺保留 dev 登录态,不能把 dev Cookie 合并进正式 profile | - -生产升级器默认只处理正式 identifier。dev profile 应由显式开发者迁移开关处理,以免普通用户机器上的历史测试 profile 污染生产数据。 - -### 7.4 Cookie 名称变更 - -Cookie 是否迁移取决于服务端 session 的生命周期,不能看到 `Cookies` 数据库里有旧名称就统一改名: - -| 状态 | 旧名 → 新名 | 生命周期/作用 | 迁移结论 | -|---|---|---|---| -| 已核对 | `bitfun_html_preview` → `openbitfun_html_preview` | Desktop 每次 `html_preview_create` 生成随机 token、随机 localhost 端口和进程内 preview server;Cookie 无 `Max-Age`/`Expires`,release 或进程退出后 server 状态消失 | 不迁移。旧 token 对新进程没有授权意义;即使 UDF 中残留也应排除/忽略,不得复制为新名 | -| 已核对 | `bitfun_market_session` + `bitfun_market_csrf` → `openbitfun_market_*`;`bitfun_skin_session` + `bitfun_skin_csrf` → `openbitfun_skin_*` | MiniApp/Skin Market 的 7 天 Web session;session token 在服务端 DB 中校验,CSRF token 必须与其 hash 配对,Cookie 分别 scoped 到 `/miniapp`、`/skin` | P1(承诺保留市场登录态时)。优先让服务端在有限兼容期接受完整旧 session+CSRF 对,验证仍有效后重新签发/设置新名称并 expire 旧名称;不要直接编辑 Chromium Cookies DB,也不能只改 session cookie 而漏 CSRF。若不承诺登录态则明确要求重新登录 | -| 已核对 | `bitfun_page_access` → `openbitfun_page_access` | Relay Pages 的 8 小时浏览器 grant;token 只映射到 Relay 进程内 `PageAccessManager`,服务重启即失效 | 不迁移。升级重启后旧 grant 本来不可恢复;用户重新完成 Page 登录/授权即可。不能把它与 Relay DB 或账户 session 混为一谈 | - -市场 Cookie 可能存在于 Desktop WebView,也可能存在于普通外部浏览器;迁移系统只能处理产品管理的 UDF。外部浏览器应依赖服务端旧名兼容和安全重签,不能扫描或修改用户浏览器 profile。 - -### 7.5 Installer WebView identifier - -安装器也从 `com.bitfun.installer` 改为 `com.openbitfun.installer`,会形成新的 Tauri app-data/WebView profile。对更名前后安装器源码的定向扫描没有发现 Local Storage、sessionStorage 或 IndexedDB owner;安装步骤状态保存在 React 进程内,最终通过 command 写入产品配置和 installer state。 - -| 状态 | 旧位置 | 作用 | 优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | Windows `%APPDATA%/com.bitfun.installer`、`%LOCALAPPDATA%/com.bitfun.installer/EBWebView` | Installer 自己的 Tauri/WebView profile;未发现应用级持久 store | 不迁移 | 默认视为安装器运行缓存/profile,由新安装器重建;不要把它并入 Desktop 的 `com.openbitfun.desktop` UDF | -| 已核对 | Installer command 写入的 `%APPDATA%/bitfun/config/installer-state.json`、语言/主题/模型等产品配置 | 安装结果和用户选择,owner 在产品用户目录而非 Installer WebView | 按对应产品 store 分项迁移 | `installer-state.json` 按 4.1/第 8 节处理;配置字段随 `app.json`/CLI config 的 schema-aware 迁移,不能因为 Installer profile 不迁而漏掉这些输出 | - -## 8. Windows 安装目录、注册表与快捷方式 - -这一节属于**升级系统集成**而非用户数据复制,但品牌更名后若不处理,会得到两个并存安装、两个卸载项或失效快捷方式。当前新安装器只读取 OpenBitFun 名称的注册表键,并且总是把用户选择的父目录规范化到 `OpenBitFun` 子目录;它不会识别或原地接管旧 `BitFun` 安装。 - -| 状态 | 旧对象 | 新对象 | 作用/优先级 | 迁移方式与注意事项 | -|---|---|---|---|---| -| 已核对 | 默认 `%LOCALAPPDATA%/BitFun`;自定义父目录下的 `BitFun/` | 对应 `OpenBitFun/` | 已安装二进制与资源;P0 升级流程、非用户数据 | 不把旧安装目录当用户数据搬迁。先从旧注册表确认真实位置和签名/版本,正常安装并验证新 payload,再调用受信任的旧卸载路径或按 manifest 清理旧 payload。发现未知文件时保全并报告,不能递归删除整个旧目录 | -| 已核对 | `bitfun-desktop.exe` | `openbitfun-desktop.exe` | 主程序入口;P0 | 所有快捷方式、PATH、Run 值和卸载记录必须成组更新;不能只改文件名,因为签名、资源和 sidecar 也随发行包变化 | -| 已核对 | `HKCU/HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\BitFun` | `...\Uninstall\OpenBitFun` | Add/Remove Programs、安装位置、旧卸载命令;P0 | 旧键只用于发现和调用旧卸载器,不能复制后字符串替换其中命令。新安装成功后写新键,再移除确认属于旧产品的键;兼顾 per-user/per-machine hive | -| 已核对 | `HKCU/HKLM\Software\BitFun Team\BitFun` | `...\OpenBitFun Team\OpenBitFun` | Tauri NSIS `MANUPRODUCTKEY` 安装位置;P0 | 读取旧默认值作为发现证据;不要把旧 `BitFun` 末段直接交给当前 `prepare_install_target`,否则会形成 `BitFun\OpenBitFun` 嵌套目录 | -| 已核对 | Desktop/Start Menu 的 `BitFun.lnk`、可能的 `Programs\BitFun\` | `OpenBitFun.lnk`、当前 Start Menu 布局 | 用户入口;P1 | 新快捷方式验证目标存在后再删旧快捷方式;不能原样复制 `.lnk`,其中目标仍指向旧 exe | -| 已核对 | `HKCU\...\Run` 的 `BitFun` 值、用户 PATH 中的旧安装目录 | `OpenBitFun` 值、新安装目录 | 自动启动和 CLI/程序发现;P0/P1 | 若用户原先启用则等价迁移,避免双启动;PATH 精确按规范化目录元素替换,不做子串替换。目标验证前保留旧入口 | -| 已核对 | `HKCU\Software\Classes\Directory\{Background\}shell\BitFun` | 当前产品不再注册该菜单 | 退休的资源管理器右键菜单;不迁移 | 新版验证成功后清理确认为旧产品的键;当前卸载代码只清理 `OpenBitFun` 名称,升级路径还需显式覆盖旧键 | -| 已核对 | `%APPDATA%/bitfun/config/installer-state.json` | `%APPDATA%/openbitfun/config/installer-state.json` | 最近安装路径;P2 | 与 4.1 同一 store。只能作为候选提示,必须再与旧注册表和实际签名 payload 交叉验证;它不能证明安装仍有效 | - -安装器在安装末期还会写新根的首次启动语言、主题和模型配置。因此品牌迁移必须先运行,或让安装器通过同一个 schema-aware merge owner 写入;否则安装器会先制造“目标非空”,使后续迁移误判为一次普通全新安装。 - -## 9. Android、iOS 与 HarmonyOS 应用数据 - -这一组不在 Desktop 的两个根目录内,但同一个更名提交同时修改了移动端 package/bundle ID 和 store 名。若这些客户端已向用户发布,它们属于独立的 P0 升级迁移项目。 - -### 9.1 跨沙箱边界 - -| 平台 | 旧应用标识 | 新应用标识 | 直接影响 | 迁移边界 | -|---|---|---|---|---| -| Android | `com.bitfun.mobile`(debug 为 `.debug`) | `com.openbitfun.mobile`(debug 为 `.debug`) | 产生新的 `/data/user//` 私有目录、UID 和 AndroidKeyStore 访问域;不是同一 package 的覆盖升级 | 首选保持已发布 application ID 不变,只改代码 namespace/品牌。若新 ID 已不可逆,需先发布由旧签名应用执行的显式 exporter/受签名权限保护的桥接,再由新应用导入;新应用不能直接复制旧 SharedPreferences/DB/Keystore | -| iOS | `com.bitfun.mobile.ios` | `com.openbitfun.mobile.ios` | 新 data container、UserDefaults domain;默认 Keychain access group 也会随签名 app identifier 改变 | 首选保持已发布 bundle ID。否则需在旧版仍可更新时建立 App Group/Keychain access group 或加密导出协议,并验证 provisioning entitlement;仅保持相同 Team ID 不足以证明新版可读旧 Keychain | -| HarmonyOS | `com.bitfun.app` | `com.openbitfun.app` | 新应用沙箱、Preferences/RDB/HUKS 域;distributed KV 的隔离还取决于 bundleName + storeId | 首选保持已发布 bundleName。否则由旧应用先解密并导出,再通过平台支持的受控通道导入;要同时覆盖手机和手表,不能只迁一端的 distributed KV | - -提交后的当前 runtime owner 未保留下表旧名称的兼容读取;而 package/bundle ID 已经先把旧数据隔离在另一个应用域。因此“新应用启动时检查旧文件名”本身并不能完成移动端迁移。 - -### 9.2 Android/iOS 共享数据域 - -| 状态 | 旧 store | 新 store | 作用/优先级 | 转换与注意事项 | -|---|---|---|---|---| -| 已核对 | `bitfun-mobile.db` | `openbitfun-mobile.db` | SQLDelight:本地 General Chat Session/message、composer draft,以及按设备保存的远端 Session 列表、transcript 和 replay cursor;用户聊天/草稿 P0,远端投影 P1 | 由旧应用导出逻辑在数据库一致性点读取,再让新 schema owner transaction 导入全部关联表;不能复制正在打开的 SQLite 文件,也不能只迁 Session 不迁 message/draft/cursor | -| 已核对 | Android `bitfun_install/install_id`;iOS UserDefaults `bitfun.mobile.install_id` | 对应 `openbitfun_*` / `openbitfun.mobile.*` | 移动客户端向 Desktop/Relay 呈现的稳定设备身份;P0 | 保持原值,避免升级被当作全新设备并留下孤立路由。目标已有不同 ID 时不能覆盖,应进入账户/设备冲突流程 | -| 已核对 | Android `bitfun_app_settings`;iOS `bitfun.mobile.language` | 对应 OpenBitFun 名称 | 主题(Android)与语言偏好;P1/P2 | 只导入当前枚举/locale contract 接受的值;目标已由用户设置时保留目标 | -| 已核对 | Android `bitfun_secure_cloud_account` + AndroidKeyStore alias `bitfun.mobile.cloud_account`;iOS Keychain service `com.bitfun.mobile.account` | 对应 OpenBitFun secure domain | `cloud_account_session`:relay URL、username、token、user ID、account master key、目标设备选择;P0/高敏感 | 必须由旧安全域解密读取、校验记录后重新加密写入新域;不能复制 Android preferences 密文或只改 alias/service。全部写入成功后才建立已登录状态 | -| 已核对 | Android `bitfun_secure_general-chat` + alias `bitfun.mobile.general-chat`;iOS service `com.bitfun.mobile.generalchat` | 对应 OpenBitFun secure domain | General Chat base URL、model、API key、selected model ID;P0/P1/高敏感 | 四个逻辑字段成组迁移,API key 不得出现在迁移日志;目标存在时按完整 provider 配置裁决,不能混出“旧 URL + 新 key” | -| 已核对 | Android `bitfun_secure_pairing_protection` + alias `bitfun.mobile.pairing_protection`;iOS service `com.bitfun.mobile.pairing` | 对应 OpenBitFun secure domain | `user_id_protection` 连续失败次数和 lockout 截止时间;P0(安全状态) | 保留仍未过期的冷却状态;过期记录由 owner 清除。迁移失败不能被解释为一次成功配对,也不能用它替代 install identity | - -### 9.3 HarmonyOS store 级清单 - -| 状态 | 旧 store/alias | 新 store/alias | 作用/优先级 | 转换与注意事项 | -|---|---|---|---|---| -| 已核对 | Preferences `bitfun_remote_identity` | `openbitfun_remote_identity` | install/device identity;P0 | 保持有效身份;目标冲突不覆盖 | -| 已核对 | Preferences `bitfun_cloud_account` + HUKS alias `bitfun_cloud_account_session` | 对应 `openbitfun_*` | 云账户 session 密文与加密 key;P0/高敏感 | 在旧应用域中解密并验证,随后用新 HUKS key 重新加密;复制 preferences 密文后改 alias 无法解密 | -| 已核对 | RDB `bitfun_general_chat.db` | `openbitfun_general_chat.db` | General Chat 本地 Session、消息和草稿;P0 | 按 RDB schema/transaction 导出导入,保留 Session-message-draft 引用闭包 | -| 已核对 | Preferences `bitfun_general_chat_config` + HUKS alias `bitfun_general_chat_api_key` | 对应 `openbitfun_*` | General Chat endpoint/model/选择和 API key;P0/P1/高敏感 | 配置与 secret 成组迁移并重新加密;不能分别采用不同侧的 provider 字段 | -| 已核对 | RDB `bitfun_remote_sessions.db`、`bitfun_remote_chat.db` | 对应 `openbitfun_*` | 远端 Session 列表、消息、cursor/离线投影;P1 | 按 device/session 主键合并,目标事实优先;解析失败保留旧记录而不是重置空库 | -| 已核对 | Preferences `bitfun_app_locale` | `openbitfun_app_locale` | locale;P1/P2 | 校验当前 locale contract 后导入 | -| 已核对 | distributed KV `bitfun_harmony_handoff_s1` | `openbitfun_harmony_handoff_s1` | 手机与手表间的账户/控制 handoff;P0(活动交接) | store ID 和 bundleName 都参与隔离。旧端、新端及手表必须按同一版本化协议双读/转存;只改 store ID 会让两端看到空 store | -| 已核对 | KV key `bitfun.account.provision.request`、`bitfun.account.provision.response` | `openbitfun.account.provision.request`、`openbitfun.account.provision.response` | phone/watch provisioning 的活动 request/response;P0(活动交接) | 与 handoff store ID 和 bundleName 作为一个协议版本迁移。兼容窗口内两端双读旧/新 key,并按 request identity 配对;不能只复制 response、只改 store ID,或让旧 response 应答不相关的新 request | - -移动端凭据导出必须是用户可感知、短时、一次性的加密迁移流程,并且在新应用确认导入前不删除旧应用数据。若旧包已无法更新、平台又没有共享容器/备份恢复能力,应明确报告“无法自动迁移并需重新登录”,不能伪造迁移成功。 - -## 10. 迁移系统必须遵守的合并与失败策略 - -1. **旧源只读且不删除。** 首次成功也不要立即删除旧目录;保留可回退窗口,由单独的显式清理流程处理。 -2. **在各 store 首次打开前执行。** SQLite、密钥 vault、WebView UDF 等不能在新旧进程仍持有写锁时复制。 -3. **新目录为空时原子提交。** 先写同卷 staging,校验成功后 rename/replace;失败不留下“看似已迁移”的半成品。 -4. **新目录已有数据时按记录合并。** 不允许整目录覆盖。默认保留新数据;ID 冲突应保留双方或由 owning store 明确裁决,并写入可见报告。 -5. **凭据必须先读旧、再写新、再提交 metadata。** 解密失败不能静默等价为“用户未登录”,也不能先删除旧 secret。 -6. **SQLite 使用 backup/transaction。** 不单独复制 `.sqlite` 主文件;WAL 中可能还有未 checkpoint 的提交。 -7. **迁移可重入。** 维护 migration manifest,记录源/目标、store 版本、hash 或稳定摘要、每项状态和错误;重复启动只重试未完成/可重试项。 -8. **解析失败保留并显式降级。** 不能通过删除、重置或静默创建空配置来“恢复”。 -9. **远程路径保持 POSIX 语义。** `remote_ssh` 和远端 dispatch/workspace 记录不能用控制端 Windows path join/split 重新解释。 -10. **敏感内容不进入日志。** 报告文件只记录 store、路径、错误类别和摘要,不输出 Token、Cookie、密钥、配置值、会话文本或终端内容。 - -## 11. 静态核对结论与实现阶段待办 - -本清单已完成对已知产品根、workspace runtime、Session/Dispatch、Remote Connect、SSH、WebView/browser storage、CLI updater、Hosted Market、安装系统、Android/iOS/HarmonyOS,以及品牌提交中相关 storage key/service name/路径名的定向静态核对。没有展开 4,748 文件的完整 diff;结论来自当前 owner 与 `8784bbc^` 对应文件的选择性比较。 - -“静态核对完成”不等于迁移系统已经可发布。实现阶段仍需: - -1. 建立最早期 migration bootstrap,在 `PathManager`、CLI config、SQLite、vault、WebView 和各页面 store 首次打开前完成源发现;旧根覆盖环境变量必须在这里处理。 -2. 建立显式 store registry。每项声明 source、target、owner、schema/version、敏感级别、冲突策略、可重入 marker 和验证器;不提供“递归复制整个旧根”的后门。 -3. 先实现 P0 owner:全局配置、workspace registry、Session/runtime-events、设备/账户身份、权限/cron、Remote Connect、SSH、移动端身份/凭据、活动 Dispatch/worktree、Market DB/artifact。 -4. 为 URI、metadata、wire key、pairing link 和混合版本 peer 增加兼容期双读/能力协商;持久历史保留原始值,新写入只用 OpenBitFun canonical 名称。 -5. 对旧 payload 做 fixture 驱动的迁移测试:legacy deserialize、目标为空、目标已有数据、冲突、损坏/WAL、重复执行、失败回滚、old-payload round trip 和敏感日志无泄漏。仅用当前版本新写数据的测试不算升级覆盖。 -6. 提供默认 dry-run 和机器可读/用户可读报告,只输出路径、store、数量、摘要和错误类别;实际迁移继续保持旧源只读。清理旧数据必须是迁移验证后的独立显式动作。 -7. 分别验证 Windows/macOS/Linux,本地 workspace、Remote workspace、Remote control、Peer Device、Detached Dispatch,以及 Hosted Market offline cutover。当前文档没有任何运行证据,不能用本地静态检查替代这些场景。 - -对于 `data/telemetry/uid`、旧 workspace `memory/` 和任意机器上出现的未知非空目录,当前结论仍是“保全并报告,不导入 active store”。这不是遗漏项,而是没有可靠 owner/schema 时的明确安全边界。 - -## 12. 主要证据文件索引 - -| 数据域 | 主要当前 owner/证据 | -|---|---| -| 产品路径与 identity | `src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs`;`src/crates/contracts/core-types/src/product_identity.rs`;`src/apps/cli/src/config.rs` | -| CLI updater 状态 | `src/apps/cli/src/self_update.rs` | -| Session/workspace runtime | `src/crates/assembly/core/src/agentic/persistence/manager.rs`;`src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs` | -| tool path URI | `src/crates/execution/tool-contracts/src/framework.rs`;`src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs` | -| Remote Connect identity/session/sync | `src/crates/services/services-integrations/src/remote_connect/device.rs`;`session_store.rs`;`sync_state.rs`;`src/crates/services/services-integrations/src/remote_connect.rs` | -| MiniApp Market 客户端凭据 | `src/crates/services/services-integrations/src/miniapp_market/credentials.rs` | -| Web UI storage | `src/web-ui/src/shared/constants/app.ts` 及第 7.1 节各 key 的直接 reader;`src/web-ui/src/infrastructure/appearance/storage/AppearanceStorage.ts` | -| Mobile Web storage | `src/mobile-web/src/pages/PairingPage.tsx`;`src/mobile-web/src/pages/ChatPage.tsx`;`src/mobile-web/src/services/RemoteCache.ts`;theme/i18n providers | -| Hosted Market | `deploy/openbitfun-host/migrate-market-data-v1.py`;`deploy/miniapp-market/`;`deploy/skin-market/` | -| Installer identifier | `OpenBitFun-Installer/src-tauri/tauri.conf.json`;更名前路径 `BitFun-Installer/src-tauri/tauri.conf.json` | -| HarmonyOS handoff/provision | `src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchHandoffStore.ets`;`WatchProvisionProtocol.ets` | -| 品牌前后对照 | 当前 checkout 与提交父版本 `8784bbc^` 的上述对应文件;未使用完整 diff 作为结论来源 | diff --git a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs index 8aca0f10eb..d66a9c4130 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs @@ -16,7 +16,7 @@ use openbitfun_core_types::{ #[cfg(target_os = "windows")] use openbitfun_legacy_migration::{ launch_trusted_executable, probe_legacy_source, HandoffStore, MigrationOnboardingStore, - MigrationRoots, PlatformExecutableTrustVerifier, ProbeLimits, TrustedInstallationResolver, + MigrationRoots, ProbeLimits, TrustedInstallationResolver, }; #[cfg(target_os = "windows")] use openbitfun_product_domains::legacy_migration::{ @@ -938,10 +938,9 @@ pub(crate) fn launch_legacy_data_migrator( &desktop, MAIN_APP_EXE, DATA_MIGRATOR_EXE, - &PlatformExecutableTrustVerifier, ) .map_err(|_| { - "The installed Data Migrator is missing or its signature cannot be trusted. Repair the OpenBitFun installation." + "The installed Data Migrator is missing or failed installation layout checks. Repair the OpenBitFun installation." .to_string() })?; launch_trusted_executable( diff --git a/scripts/data-migrator-tauri-build.test.mjs b/scripts/data-migrator-tauri-build.test.mjs index 54129c307f..8d2cad1615 100644 --- a/scripts/data-migrator-tauri-build.test.mjs +++ b/scripts/data-migrator-tauri-build.test.mjs @@ -61,6 +61,7 @@ test('Data Migrator dependency and command closure stays migration-only', () => } assert.match(source, /product_assembly_plan_for_profile\(DeliveryProfile::DataMigrator\)/); assert.match(registration, /tauri::generate_handler!/); + assert.match(registration, /commands::export_migration_diagnostics/); assert.match( readFileSync(join(ROOT, 'scripts', 'data-migrator-tauri-build.mjs'), 'utf8'), /windowsHide:\s*true/, @@ -69,3 +70,23 @@ test('Data Migrator dependency and command closure stays migration-only', () => assert.equal(capability.includes(forbidden), false, `capability must not include ${forbidden}`); } }); + +test('Data Migrator gates onboarding actions on authenticated bootstrap', () => { + const html = readFileSync(join(APP, 'ui', 'index.html'), 'utf8'); + const source = readFileSync(join(APP, 'ui', 'app.js'), 'utf8'); + + assert.match(html, /
]+hidden>/); + assert.match(source, /function requireBootstrap\(\)/); + assert.match(source, /if \(!requireBootstrap\(\)\) return;/); + assert.match(source, /catch \(error\) \{\s+notice\(/); +}); + +test('Data Migrator labels unverified report counts as staged', () => { + const source = readFileSync(join(APP, 'ui', 'app.js'), 'utf8'); + + assert.match( + source, + /result\.state === 'verified' \? text\.imported : text\.staged/, + ); + assert.match(source, /\$\{result\.imported\} \$\{transferLabel\(result\)\}/); +}); diff --git a/src/apps/data-migrator/AGENTS.md b/src/apps/data-migrator/AGENTS.md index 3fdd584170..5593d2ca29 100644 --- a/src/apps/data-migrator/AGENTS.md +++ b/src/apps/data-migrator/AGENTS.md @@ -21,9 +21,9 @@ must remain a separate executable and WebView identity from Desktop. - Cancellation is advisory and may be honored only at engine-declared safe boundaries. Closing during execution requests cancellation and keeps the window open until a safe boundary. -- The migrator never updates itself. Resolve Desktop as a verified sibling +- The migrator never updates itself. Resolve Desktop as a fixed-name sibling binary using product-definition projections and the trusted installation - resolver. + resolver; do not accept executable paths from handoff input. ## Verification diff --git a/src/apps/data-migrator/src/app_state.rs b/src/apps/data-migrator/src/app_state.rs index dc74d4ded7..83df00a76e 100644 --- a/src/apps/data-migrator/src/app_state.rs +++ b/src/apps/data-migrator/src/app_state.rs @@ -1,15 +1,14 @@ use openbitfun_core::legacy_migration::adapters_for_groups; use openbitfun_core_types::product_identity::product_id; use openbitfun_legacy_migration::{ - blocking_writer_processes_for_product, launch_trusted_executable, probe_legacy_source, - CancellationToken, HandoffDisposition, HandoffStore, LegacyMigrationError, + blocking_writer_processes_for_product, export_failure_diagnostics, launch_trusted_executable, + probe_legacy_source, CancellationToken, HandoffDisposition, HandoffStore, LegacyMigrationError, LegacyMigrationResult, MigrationEngine, MigrationLayout, MigrationOnboardingStore, - MigrationRoots, NoCrashInjection, PlatformExecutableTrustVerifier, ProbeLimits, - TrustedInstallationResolver, WriterProcess, + MigrationRoots, NoCrashInjection, ProbeLimits, TrustedInstallationResolver, WriterProcess, }; use openbitfun_product_capabilities::{product_assembly_plan_for_profile, DeliveryProfile}; use openbitfun_product_domains::legacy_migration::{ - LegacySourceDescriptor, MigrationPhase, MigrationPlan, MigrationProgressEvent, + FindingSeverity, LegacySourceDescriptor, MigrationPhase, MigrationPlan, MigrationProgressEvent, MigrationPromptChoice, MigrationRunReport, MigrationRunStatus, MigrationSelection, MigratorHandoffRequest, MigratorProtocolCapabilities, MigratorRequestMode, ScanFinding, CURRENT_MIGRATION_FORMAT_VERSION, @@ -33,6 +32,24 @@ const DATA_MIGRATOR_BINARY_NAME: &str = match option_env!("OPENBITFUN_DATA_MIGRA None => "openbitfun-data-migrator", }; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FinishAction { + CloseForDevRestart, + RestartDesktop, +} + +const fn finish_action() -> FinishAction { + finish_action_for_build(cfg!(debug_assertions)) +} + +const fn finish_action_for_build(is_debug_build: bool) -> FinishAction { + if is_debug_build { + FinishAction::CloseForDevRestart + } else { + FinishAction::RestartDesktop + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CommandError { @@ -143,12 +160,33 @@ impl CommandError { ), } } + + fn from_report_failure(report: &MigrationRunReport) -> Option { + let diagnostic = report.diagnostics.iter().rev().find(|diagnostic| { + diagnostic.severity == FindingSeverity::Blocking + && diagnostic.domain.is_some() + && diagnostic.code.starts_with("domain_") + })?; + let mut message = diagnostic.message.clone(); + if let Some(action) = diagnostic.action.as_deref() { + if !message.is_empty() && !message.ends_with(char::is_whitespace) { + message.push(' '); + } + message.push_str(action); + } + Some(Self { + code: diagnostic.code.clone(), + message, + recoverable: true, + }) + } } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct MigratorView { pub delivery_profile: String, + pub restart_desktop_on_finish: bool, pub protocol: MigratorProtocolCapabilities, pub mode: MigratorRequestMode, pub source: Option, @@ -165,6 +203,12 @@ pub(crate) struct MigratorView { pub error: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DiagnosticsExportView { + pub file_path: String, +} + #[derive(Debug)] struct MigratorSession { roots: MigrationRoots, @@ -270,6 +314,32 @@ impl MigratorCoordinator { snapshot(&session) } + pub(crate) fn export_diagnostics(&self) -> Result { + let session = self.lock(); + if session.running { + return Err(CommandError::operation_in_progress()); + } + let report = session.report.clone().ok_or_else(|| { + CommandError::new( + "diagnostics_unavailable", + "Failure diagnostics are available after a migration failure.", + false, + ) + })?; + let layout = MigrationLayout::new(&session.roots, &report.run_id); + drop(session); + let path = export_failure_diagnostics(&layout, &report).map_err(|_| { + CommandError::new( + "diagnostics_export_failed", + "OpenBitFun could not write the sanitized migration diagnostics file.", + true, + ) + })?; + Ok(DiagnosticsExportView { + file_path: path.to_string_lossy().to_string(), + }) + } + pub(crate) fn scan(&self, selection: MigrationSelection) -> Result { let (roots, request, cancellation) = self.begin_operation(&selection)?; let coordinator = self.clone(); @@ -504,7 +574,10 @@ impl MigratorCoordinator { let result = (|| { self.persist_prompt_choice(choice)?; - self.restart_desktop() + match finish_action() { + FinishAction::CloseForDevRestart => Ok(()), + FinishAction::RestartDesktop => self.restart_desktop(), + } })(); if let Err(error) = result { let mut session = self.lock(); @@ -707,7 +780,6 @@ impl MigratorCoordinator { ¤t, DATA_MIGRATOR_BINARY_NAME, DESKTOP_BINARY_NAME, - &PlatformExecutableTrustVerifier, )?; let arguments = [OsStr::new("--legacy-migration-run-id"), OsStr::new(&run_id)]; launch_trusted_executable(&executable, &arguments)?; @@ -727,7 +799,11 @@ impl MigratorCoordinator { progress.code = "migration_cancelled".to_string(); } } - let command_error = CommandError::from_legacy(error); + let command_error = session + .report + .as_ref() + .and_then(CommandError::from_report_failure) + .unwrap_or_else(|| CommandError::from_legacy(error)); session.error = Some(command_error.clone()); command_error } @@ -803,6 +879,7 @@ fn snapshot(session: &MigratorSession) -> MigratorView { let report = session.report.as_ref().map(redact_report_for_ui); MigratorView { delivery_profile: DeliveryProfile::DataMigrator.id().to_string(), + restart_desktop_on_finish: finish_action() == FinishAction::RestartDesktop, protocol: MigratorProtocolCapabilities::current(), mode: session.request.mode, source: session.source.clone(), @@ -955,6 +1032,7 @@ mod tests { let view = coordinator.snapshot(); assert_eq!(view.delivery_profile, "data-migrator"); + assert!(!view.restart_desktop_on_finish); assert_eq!(view.mode, MigratorRequestMode::Onboarding); assert!(view.source.is_some()); assert!(!view.recovery); @@ -1020,4 +1098,37 @@ mod tests { assert!(!serialized.contains("private")); assert!(!serialized.contains("secret")); } + + #[test] + fn report_failure_errors_use_sanitized_domain_diagnostics() { + let report = MigrationRunReport { + diagnostics: vec![ + openbitfun_product_domains::legacy_migration::MigrationDiagnostic { + code: "domain_io_permission_denied".to_string(), + severity: FindingSeverity::Blocking, + domain: Some( + openbitfun_product_domains::legacy_migration::MigrationDomainId::WorkspaceSessions, + ), + relative_path: Some("C:/Users/private/session-state.json".to_string()), + message: "A migration-owned file or directory denied access.".to_string(), + action: Some("Close programs using the data, check permissions, and retry.".to_string()), + }, + ], + ..MigrationRunReport::default() + }; + + let command = CommandError::from_report_failure(&report).unwrap(); + let serialized = serde_json::to_string(&command).unwrap(); + + assert_eq!(command.code, "domain_io_permission_denied"); + assert!(command.message.contains("check permissions")); + assert!(!serialized.contains("private")); + assert!(!serialized.contains("session-state")); + } + + #[test] + fn finish_action_preserves_release_restart_and_closes_debug() { + assert_eq!(finish_action(), FinishAction::CloseForDevRestart); + assert_eq!(finish_action_for_build(false), FinishAction::RestartDesktop); + } } diff --git a/src/apps/data-migrator/src/commands.rs b/src/apps/data-migrator/src/commands.rs index 439a46f09f..be2dd043da 100644 --- a/src/apps/data-migrator/src/commands.rs +++ b/src/apps/data-migrator/src/commands.rs @@ -1,4 +1,4 @@ -use crate::app_state::{CommandError, MigratorCoordinator, MigratorView}; +use crate::app_state::{CommandError, DiagnosticsExportView, MigratorCoordinator, MigratorView}; use openbitfun_product_domains::legacy_migration::{MigrationPromptChoice, MigrationSelection}; use serde::Deserialize; use tauri::{AppHandle, State}; @@ -76,6 +76,15 @@ pub(crate) fn cancel_legacy_migration( state.cancel() } +#[tauri::command] +pub(crate) fn export_migration_diagnostics( + state: State<'_, MigratorCoordinator>, + request: EmptyRequest, +) -> Result { + let _ = request; + state.export_diagnostics() +} + #[tauri::command] pub(crate) fn finish_legacy_migration( app: AppHandle, diff --git a/src/apps/data-migrator/src/lib.rs b/src/apps/data-migrator/src/lib.rs index 2b141603d3..64894deb98 100644 --- a/src/apps/data-migrator/src/lib.rs +++ b/src/apps/data-migrator/src/lib.rs @@ -46,6 +46,7 @@ pub fn run(run_id: &str) -> Result<(), RunError> { commands::retry_writer_check, commands::start_legacy_migration, commands::cancel_legacy_migration, + commands::export_migration_diagnostics, commands::finish_legacy_migration, ]) .run(tauri::generate_context!()) diff --git a/src/apps/data-migrator/ui/app.js b/src/apps/data-migrator/ui/app.js index 97e55fc2d6..3d230ea1e9 100644 --- a/src/apps/data-migrator/ui/app.js +++ b/src/apps/data-migrator/ui/app.js @@ -15,13 +15,19 @@ const translations = { phase: 'Phase', domain: 'Domain', count: 'Completed steps', cancel: 'Cancel at a safe boundary', stepDone: 'Result', reportTitle: 'Migration report', reportPrivacy: 'This summary contains counts and result codes, not credentials or user content.', + exportDiagnostics: 'Export failure diagnostics', + diagnosticsExported: 'Sanitized diagnostics saved to {path}', openDesktop: 'Open OpenBitFun', ready: 'Ready', unsupported: 'Unsupported', missing: 'Not found', + closeMigrator: 'Close Data Migrator', + devRestartHelp: 'Development build: close Data Migrator, then run pnpm run desktop:dev again.', + bootstrapPending: 'Data Migrator is still loading. Please try again.', + bootstrapFailed: 'Data Migrator could not load its authenticated migration request.', sourceFound: 'BitFun {version} was found. The source stays read-only.', recovery: 'A previous migration journal was found and can be resumed.', blockers: '{count} data-writing process(es) must stop before migration can continue.', noBlockers: 'No data-writing processes are blocking migration.', steps: '{count} ordered domain step(s)', conflicts: '{count} visible conflict(s)', - imported: 'imported', skipped: 'skipped', warnings: 'warnings', + imported: 'imported', staged: 'staged', skipped: 'skipped', warnings: 'warnings', }, 'zh-CN': { eyebrow: 'OpenBitFun 数据维护', title: '从 BitFun 导入数据', @@ -34,13 +40,19 @@ const translations = { planTitle: '确认迁移', retryWriters: '重新检查进程', start: '开始迁移', stepProgress: '第 5 步', progressTitle: '迁移进度', phase: '阶段', domain: '领域', count: '已完成步骤', cancel: '在安全边界取消', stepDone: '结果', reportTitle: '迁移报告', - reportPrivacy: '此摘要仅包含计数和结果码,不包含凭据或用户正文。', openDesktop: '打开 OpenBitFun', + reportPrivacy: '此摘要仅包含计数和结果码,不包含凭据或用户正文。', + exportDiagnostics: '导出失败诊断', diagnosticsExported: '去敏诊断已保存到 {path}', + openDesktop: '打开 OpenBitFun', + closeMigrator: '关闭数据迁移器', + devRestartHelp: '开发版本:关闭数据迁移器,然后重新运行 pnpm run desktop:dev。', + bootstrapPending: '数据迁移器仍在加载,请稍后重试。', + bootstrapFailed: '数据迁移器无法加载已认证的迁移请求。', ready: '可迁移', unsupported: '不受支持', missing: '未发现', sourceFound: '已发现 BitFun {version}。迁移期间来源保持只读。', recovery: '发现上次迁移日志,可以从安全状态继续。', blockers: '迁移前还需停止 {count} 个数据写入进程。', noBlockers: '没有进程阻止迁移。', steps: '{count} 个有序领域步骤', conflicts: '{count} 个可见冲突', - imported: '已导入', skipped: '已跳过', warnings: '警告', + imported: '已导入', staged: '已暂存', skipped: '已跳过', warnings: '警告', }, 'zh-TW': { eyebrow: 'OpenBitFun 資料維護', title: '從 BitFun 匯入資料', @@ -53,13 +65,19 @@ const translations = { planTitle: '確認遷移', retryWriters: '重新檢查程序', start: '開始遷移', stepProgress: '第 5 步', progressTitle: '遷移進度', phase: '階段', domain: '領域', count: '已完成步驟', cancel: '在安全邊界取消', stepDone: '結果', reportTitle: '遷移報告', - reportPrivacy: '此摘要僅包含計數和結果碼,不包含憑據或使用者正文。', openDesktop: '開啟 OpenBitFun', + reportPrivacy: '此摘要僅包含計數和結果碼,不包含憑據或使用者正文。', + exportDiagnostics: '匯出失敗診斷', diagnosticsExported: '去敏診斷已儲存至 {path}', + openDesktop: '開啟 OpenBitFun', + closeMigrator: '關閉資料遷移器', + devRestartHelp: '開發版本:關閉資料遷移器,然後重新執行 pnpm run desktop:dev。', + bootstrapPending: '資料遷移器仍在載入,請稍後重試。', + bootstrapFailed: '資料遷移器無法載入已驗證的遷移請求。', ready: '可遷移', unsupported: '不支援', missing: '未發現', sourceFound: '已發現 BitFun {version}。遷移期間來源保持唯讀。', recovery: '發現上次遷移日誌,可以從安全狀態繼續。', blockers: '遷移前還需停止 {count} 個資料寫入程序。', noBlockers: '沒有程序阻止遷移。', steps: '{count} 個有序領域步驟', conflicts: '{count} 個可見衝突', - imported: '已匯入', skipped: '已略過', warnings: '警告', + imported: '已匯入', staged: '已暫存', skipped: '已略過', warnings: '警告', }, }; @@ -122,6 +140,12 @@ function notice(message) { node.hidden = !message; } +function requireBootstrap() { + if (current) return true; + notice(text.bootstrapPending); + return false; +} + function row(title, detail) { const item = document.createElement('div'); item.className = 'result-row'; @@ -133,6 +157,10 @@ function row(title, detail) { return item; } +function transferLabel(result) { + return result.state === 'verified' ? text.imported : text.staged; +} + function renderScopes(selection) { const list = document.getElementById('scope-list'); list.replaceChildren(); @@ -209,9 +237,19 @@ function render(view) { const reportSummary = document.getElementById('report-summary'); if (view.report) { reportSummary.replaceChildren(...view.report.domainResults.map((result) => - row(result.domain, `${result.imported} ${text.imported}, ${result.skipped} ${text.skipped}, ${result.warnings.length} ${text.warnings}`))); + row(result.domain, `${result.imported} ${transferLabel(result)}, ${result.skipped} ${text.skipped}, ${result.warnings.length} ${text.warnings}`))); } + show('dev-restart-help', !view.restartDesktopOnFinish); + document.getElementById('open-desktop').textContent = view.restartDesktopOnFinish + ? text.openDesktop : text.closeMigrator; show('report-card', !view.running && (Boolean(view.report) || view.status === 'cancelled')); + const canExportDiagnostics = ['failed_recoverable', 'failed_manual_action_required'].includes(view.status); + show('export-diagnostics', canExportDiagnostics); + if (!canExportDiagnostics) { + const output = document.getElementById('diagnostics-path'); + output.textContent = ''; + output.hidden = true; + } document.getElementById('start').disabled = !view.canExecute; if (view.running && !pollTimer) { @@ -240,13 +278,16 @@ async function call(command, request = {}) { async function refresh() { try { render(await invoke('get_migrator_bootstrap', { request: {} })); - } catch { + } catch (error) { + const message = error?.message || (typeof error === 'string' ? error : ''); + notice(message || text.bootstrapFailed); if (pollTimer) window.clearInterval(pollTimer); pollTimer = undefined; } } document.getElementById('migrate-now').addEventListener('click', () => { + if (!requireBootstrap()) return; show('choice-card', false); show('scope-card'); renderScopes(current.selection); @@ -265,6 +306,20 @@ document.getElementById('start').addEventListener('click', () => call('start_legacy_migration', { planHash: current.plan.planHash })); document.getElementById('cancel').addEventListener('click', () => call('cancel_legacy_migration')); +document.getElementById('export-diagnostics').addEventListener('click', async () => { + setBusy(true); + try { + const result = await invoke('export_migration_diagnostics', { request: {} }); + const output = document.getElementById('diagnostics-path'); + output.textContent = format(text.diagnosticsExported, { path: result.filePath }); + output.hidden = false; + } catch (error) { + notice(error?.message || String(error)); + } finally { + setBusy(false); + if (current) render(current); + } +}); document.getElementById('open-desktop').addEventListener('click', () => call('finish_legacy_migration', { choice: current.report ? 'migrate_now' : 'remind_later' })); diff --git a/src/apps/data-migrator/ui/index.html b/src/apps/data-migrator/ui/index.html index be9dbbff8e..38dd720570 100644 --- a/src/apps/data-migrator/ui/index.html +++ b/src/apps/data-migrator/ui/index.html @@ -31,7 +31,7 @@

Legacy source

-
+ diff --git a/src/apps/desktop/src/api/legacy_migration_api.rs b/src/apps/desktop/src/api/legacy_migration_api.rs index fe23353643..6107cf2c01 100644 --- a/src/apps/desktop/src/api/legacy_migration_api.rs +++ b/src/apps/desktop/src/api/legacy_migration_api.rs @@ -9,7 +9,7 @@ use openbitfun_core_types::product_identity::product_id; use openbitfun_legacy_migration::{ launch_trusted_executable, probe_legacy_source, CancellationToken, HandoffStore, LegacyMigrationError, LegacyMigrationResult, MigrationEngine, MigrationOnboardingStore, - MigrationRoots, PlatformExecutableTrustVerifier, ProbeLimits, TrustedInstallationResolver, + MigrationRoots, ProbeLimits, TrustedInstallationResolver, }; use openbitfun_product_domains::legacy_migration::{ LegacySourceDescriptor, MigrationOnboardingState, MigrationPromptChoice, MigrationRunReport, @@ -36,15 +36,24 @@ const DATA_MIGRATOR_BINARY_NAME: &str = match option_env!("OPENBITFUN_DATA_MIGRA }; const HANDOFF_LIFETIME_MS: i64 = 10 * 60 * 1000; -static STARTUP_HANDLED_RUN_ID: OnceLock> = OnceLock::new(); +static STARTUP_MIGRATION_STATE: OnceLock = OnceLock::new(); + +#[derive(Debug, Clone, Default)] +struct StartupMigrationState { + handled_run_id: Option, + startup_error: Option, +} #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum StartupProbeDisposition { - Continue { handled_run_id: Option }, + Continue { + handled_run_id: Option, + startup_error: Option, + }, MigratorLaunched, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct LegacyMigrationCommandError { pub code: String, @@ -78,7 +87,7 @@ impl LegacyMigrationCommandError { LegacyMigrationError::UntrustedExecutable(_) | LegacyMigrationError::TrustedInstallationUnavailable(_) => Self::new( "data_migrator_unavailable", - "The signed Data Migrator sibling is missing or cannot be trusted. Repair or update this OpenBitFun installation.", + "The installed Data Migrator is missing or failed installation layout checks. Repair or update this OpenBitFun installation.", false, ), LegacyMigrationError::LockUnavailable => Self::new( @@ -116,6 +125,18 @@ impl LegacyMigrationCommandError { ), } } + + fn from_startup_launch(error: &LegacyMigrationError) -> Self { + match error { + LegacyMigrationError::UntrustedExecutable(_) + | LegacyMigrationError::TrustedInstallationUnavailable(_) => Self::from_legacy(error), + _ => Self::new( + "data_migrator_launch_failed", + "The Data Migrator could not be started safely. OpenBitFun continued without changing legacy BitFun data.", + true, + ), + } + } } impl From for LegacyMigrationCommandError { @@ -159,6 +180,7 @@ pub struct LegacyMigrationStatusView { pub onboarding: MigrationOnboardingState, pub latest_report: Option, pub startup_report: Option, + pub startup_error: Option, } #[derive(Debug, Clone, Serialize)] @@ -185,6 +207,7 @@ pub(crate) fn run_startup_probe() -> LegacyMigrationResult LegacyMigrationResult LegacyMigrationResult Ok(StartupProbeDisposition::MigratorLaunched), + Err(error) => Ok(continue_after_startup_launch_failure(error)), + } +} + +fn continue_after_startup_launch_failure(error: LegacyMigrationError) -> StartupProbeDisposition { + StartupProbeDisposition::Continue { + handled_run_id: None, + startup_error: Some(LegacyMigrationCommandError::from_startup_launch(&error)), + } } -pub(crate) fn set_startup_handled_run_id(run_id: Option) { - let _ = STARTUP_HANDLED_RUN_ID.set(run_id); +pub(crate) fn set_startup_migration_state( + handled_run_id: Option, + startup_error: Option, +) { + let _ = STARTUP_MIGRATION_STATE.set(StartupMigrationState { + handled_run_id, + startup_error, + }); } #[tauri::command] @@ -329,9 +369,9 @@ fn migration_status() -> LegacyMigrationResult { .load_last_report()? .as_ref() .map(redact_report_for_ui); - let startup_report = STARTUP_HANDLED_RUN_ID + let startup_report = STARTUP_MIGRATION_STATE .get() - .and_then(|run_id| run_id.as_deref()) + .and_then(|state| state.handled_run_id.as_deref()) .map(|run_id| onboarding_store.load_report(run_id)) .transpose()? .flatten() @@ -342,6 +382,9 @@ fn migration_status() -> LegacyMigrationResult { onboarding, latest_report, startup_report, + startup_error: STARTUP_MIGRATION_STATE + .get() + .and_then(|state| state.startup_error.clone()), }) } @@ -451,7 +494,6 @@ fn launch_data_migrator(run_id: &str) -> LegacyMigrationResult { ¤t, DESKTOP_BINARY_NAME, DATA_MIGRATOR_BINARY_NAME, - &PlatformExecutableTrustVerifier, )?; launch_trusted_executable(&executable, &[OsStr::new(run_id)]) } @@ -617,4 +659,27 @@ mod tests { assert_eq!(redacted.diagnostics[0].message, "legacy item failed"); assert!(redacted.diagnostics[0].action.is_none()); } + + #[test] + fn startup_launch_failure_continues_with_redacted_error() { + let disposition = continue_after_startup_launch_failure(LegacyMigrationError::Io { + path: r"C:\private\openbitfun-data-migrator.exe".into(), + source: std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "private operating system detail", + ), + }); + + let StartupProbeDisposition::Continue { + handled_run_id, + startup_error: Some(error), + } = disposition + else { + panic!("launch failure must allow Desktop startup with a surfaced error"); + }; + assert!(handled_run_id.is_none()); + assert_eq!(error.code, "data_migrator_launch_failed"); + assert!(error.recoverable); + assert!(!error.message.contains("private")); + } } diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index c41822fffe..9d0955f8af 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -560,9 +560,10 @@ fn get_startup_native_trace( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub async fn run() { match api::legacy_migration_api::run_startup_probe() { - Ok(api::legacy_migration_api::StartupProbeDisposition::Continue { handled_run_id }) => { - api::legacy_migration_api::set_startup_handled_run_id(handled_run_id) - } + Ok(api::legacy_migration_api::StartupProbeDisposition::Continue { + handled_run_id, + startup_error, + }) => api::legacy_migration_api::set_startup_migration_state(handled_run_id, startup_error), Ok(api::legacy_migration_api::StartupProbeDisposition::MigratorLaunched) => return, Err(error) => { show_fatal_startup_error(&format!( diff --git a/src/crates/assembly/core/src/agentic/memories/db.rs b/src/crates/assembly/core/src/agentic/memories/db.rs index 925b3183ac..0750b5152f 100644 --- a/src/crates/assembly/core/src/agentic/memories/db.rs +++ b/src/crates/assembly/core/src/agentic/memories/db.rs @@ -3,11 +3,12 @@ use crate::infrastructure::PathManager; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use openbitfun_services_core::memory_store::{ decode_memory_job, decode_memory_record, initialize_memory_schema, upsert_memory_record, - EXPECTED_JOBS_COLUMNS, EXPECTED_STAGE1_COLUMNS, }; pub use openbitfun_services_core::memory_store::{ MemoryJobRecord as MemoryJobRow, MemoryRecord as MemoryRow, }; +#[cfg(test)] +use openbitfun_services_core::memory_store::{EXPECTED_JOBS_COLUMNS, EXPECTED_STAGE1_COLUMNS}; use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -1182,6 +1183,7 @@ fn initialize_schema(conn: &Connection) -> OpenBitFunResult<()> { }) } +#[cfg(test)] fn table_columns(conn: &Connection, table_name: &str) -> OpenBitFunResult> { let mut stmt = conn .prepare(&format!("PRAGMA table_info({table_name})")) diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs index 9fa964e3e9..56538b9ca0 100644 --- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs @@ -488,7 +488,7 @@ impl PathManager { .insert(workspace_path.to_path_buf(), slug.to_string()); } - fn build_project_runtime_slug(canonical: &str) -> String { + pub(crate) fn build_project_runtime_slug(canonical: &str) -> String { let slug: String = canonical .chars() .map(|ch| { diff --git a/src/crates/assembly/core/src/legacy_migration/agent_coordination.rs b/src/crates/assembly/core/src/legacy_migration/agent_coordination.rs index 2600f12a1e..4912442f27 100644 --- a/src/crates/assembly/core/src/legacy_migration/agent_coordination.rs +++ b/src/crates/assembly/core/src/legacy_migration/agent_coordination.rs @@ -1756,6 +1756,71 @@ mod tests { drop(wal_connection); } + #[test] + fn orphaned_session_relationship_is_preserved_without_blocking_import() { + let temp = test_tempdir("orphaned-session-relationship"); + let roots = fixture_roots(temp.path()); + copy_fixture(&roots); + let metadata_path = roots + .legacy_home_root + .join("projects/c--fixture-workspace/sessions/session-1/metadata.json"); + let mut metadata: serde_json::Value = + serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap(); + metadata["relationship"] = serde_json::json!({ + "kind": "subagent", + "parentSessionId": "missing-parent-session", + "parentDialogTurnId": "missing-parent-turn" + }); + atomic_write_json(&metadata_path, &metadata).unwrap(); + drop(materialize_coordination(&roots, false)); + let source_hash = hash_source_roots(&roots); + let selection = session_selection(); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .unwrap() + .unwrap(); + let engine = MigrationEngine::new(roots.clone(), adapters_for_groups(&selection)).unwrap(); + let plan = engine + .plan(&source, selection, &CancellationToken::default()) + .unwrap(); + + let report = engine + .execute(&plan, &CancellationToken::default(), &NoCrashInjection) + .unwrap(); + + assert_eq!(report.status, MigrationRunStatus::CompletedWithWarnings); + let sessions_result = report + .domain_results + .iter() + .find(|result| result.domain == MigrationDomainId::WorkspaceSessions) + .unwrap(); + assert_eq!(sessions_result.state, MigrationDomainState::Verified); + assert!(sessions_result + .warnings + .iter() + .any(|warning| warning.code == "session_parent_not_present")); + + let sessions_root = roots + .target_home_root + .join("projects/c--fixture-workspace/sessions"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let imported = runtime + .block_on(OfflineSessionImportStore::new(sessions_root).load_bundle("session-1")) + .unwrap() + .unwrap(); + assert_eq!( + imported + .metadata + .relationship + .as_ref() + .and_then(|relationship| relationship.parent_session_id.as_deref()), + Some("missing-parent-session") + ); + assert_eq!(hash_source_roots(&roots), source_hash); + } + #[test] fn missing_or_unknown_coordination_schema_blocks_the_session_plan() { let temp = test_tempdir("coordination-schema"); diff --git a/src/crates/assembly/core/src/legacy_migration/extensions.rs b/src/crates/assembly/core/src/legacy_migration/extensions.rs index c9f18d4eee..445eadde2e 100644 --- a/src/crates/assembly/core/src/legacy_migration/extensions.rs +++ b/src/crates/assembly/core/src/legacy_migration/extensions.rs @@ -1,8 +1,5 @@ use super::common::{read_bounded_json, stage_domain_dir}; -use openbitfun_agent_runtime::custom_agent::{ - custom_agent_read_markdown_str, custom_agent_save_markdown_file, CustomAgentDefinition, - CustomAgentLevel, -}; +use openbitfun_agent_runtime::custom_agent::{custom_agent_read_markdown_str, CustomAgentLevel}; use openbitfun_agent_runtime::skills::{SkillData, SkillLocation, OPENBITFUN_SYSTEM_SKILL_DIR}; use openbitfun_legacy_migration::{ atomic_write_bytes, atomic_write_json, DomainContext, DomainScan, LegacyDomainAdapter, @@ -14,9 +11,8 @@ use openbitfun_product_domains::legacy_migration::{ }; use openbitfun_product_domains::miniapp::builtin::{BUILTIN_APPS, BUILTIN_INSTALL_MARKER}; use openbitfun_product_domains::miniapp::storage::{ - build_import_bundle_plan, MiniAppImportBundleWriteRequest, MiniAppStorageLayout, COMPILED_HTML, - ESM_DEPS_JSON, INDEX_HTML, META_JSON, PACKAGE_JSON, REQUIRED_SOURCE_FILES, SOURCE_DIR, - STORAGE_JSON, STYLE_CSS, UI_JS, WORKER_JS, + build_import_bundle_plan, COMPILED_HTML, ESM_DEPS_JSON, META_JSON, PACKAGE_JSON, + REQUIRED_SOURCE_FILES, SOURCE_DIR, STORAGE_JSON, }; use openbitfun_product_domains::miniapp::types::MiniAppMeta; use openbitfun_services_integrations::miniapp::storage::MiniAppStorage; @@ -24,24 +20,11 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::fs; +use std::io::{BufReader, Read}; use std::path::{Path, PathBuf}; -const MAX_FILES_PER_EXTENSION: usize = 512; -const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; -const MAX_EXTENSION_BYTES: u64 = 64 * 1024 * 1024; -const SKILL_ALLOWED_DIRECTORIES: &[&str] = - &["agents", "examples", "resources", "scripts", "templates"]; -const MINIAPP_ALLOWED_FILES: &[&str] = &[ - META_JSON, - INDEX_HTML, - STYLE_CSS, - UI_JS, - WORKER_JS, - ESM_DEPS_JSON, - PACKAGE_JSON, - STORAGE_JSON, - BUILTIN_INSTALL_MARKER, -]; +const MAX_FILES_PER_EXTENSION: usize = 32_768; +const MAX_EXTENSION_BYTES: u64 = 4 * 1024 * 1024 * 1024; pub(crate) struct SkillsAdapter; pub(crate) struct MiniappsAdapter; @@ -92,7 +75,12 @@ struct PlannedAgent { action: ImportAction, content_hash: String, source_path: PathBuf, - definition: CustomAgentDefinition, + staged_content: Vec, +} + +enum PlannedMiniAppFile { + Source(PathBuf), + Generated(Vec), } impl LegacyDomainAdapter for SkillsAdapter { @@ -220,34 +208,14 @@ impl LegacyDomainAdapter for MiniappsAdapter { fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { let planned = plan_miniapps(context.roots)?; let domain_root = stage_domain_dir(context, "miniapps"); - let import_inputs = domain_root.join("import-inputs"); let output = domain_root.join("output"); let mut manifest = ImportManifest::default(); for item in &planned { match item.action { ImportAction::Import | ImportAction::Remap => { - let input = import_inputs.join(&item.target_id); - prepare_miniapp_import_input(item, &input)?; - let meta = fs::read_to_string(input.join(META_JSON)) - .map_err(|error| io(&input.join(META_JSON), error))?; - let plan = - build_import_bundle_plan(&item.target_id, &meta, 0).map_err(|error| { - LegacyMigrationError::InvalidRequest(format!( - "legacy MiniApp {} failed current owner conversion: {error}", - item.source_id - )) - })?; - MiniAppStorage::new(output.clone()) - .write_import_bundle_offline(MiniAppImportBundleWriteRequest { - source_path: input, - app_id: item.target_id.clone(), - meta_json: plan.meta_json, - esm_dependencies_json: plan.esm_dependencies_json, - package_json: plan.package_json, - storage_json: plan.storage_json, - compiled_html: plan.compiled_html, - }) - .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + let staged = output.join(&item.target_id); + write_miniapp_output(&item.source_path, &item.target_id, &staged)?; + require_hash(&staged, &item.content_hash)?; } ImportAction::BuiltinStorage => { let source = item.source_path.join(STORAGE_JSON); @@ -291,6 +259,7 @@ impl LegacyDomainAdapter for MiniappsAdapter { entry.target_id, parsed.id ))); } + require_hash(&root, &entry.content_hash)?; } for entry in manifest .entries @@ -313,11 +282,10 @@ impl LegacyDomainAdapter for MiniappsAdapter { let staged = stage_domain_dir(context, "miniapps") .join("output") .join(&entry.target_id); - let staged_hash = hash_declared_current_miniapp(&staged)?; install_directory_idempotent( &staged, &target_root.join(&entry.target_id), - &staged_hash, + &entry.content_hash, &context.plan.run_id, )?; } @@ -367,6 +335,7 @@ impl LegacyDomainAdapter for MiniappsAdapter { entry.target_id ))); } + require_hash(&root, &entry.content_hash)?; } for entry in manifest .entries @@ -430,18 +399,12 @@ impl LegacyDomainAdapter for AgentsAdapter { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|error| io(parent, error))?; } - custom_agent_save_markdown_file(&path, &item.definition).map_err(|error| { - LegacyMigrationError::InvalidRequest(format!( - "failed to stage Agent {} through the current owner: {error}", - item.target_id - )) - })?; - let content = fs::read(&path).map_err(|error| io(&path, error))?; + atomic_write_bytes(&path, &item.staged_content)?; manifest.entries.push(ImportEntry { source_id: item.source_id, target_id: item.target_id, action: item.action, - content_hash: hash_bytes(&content), + content_hash: item.content_hash, }); } else { manifest.entries.push(ImportEntry { @@ -513,7 +476,9 @@ fn plan_skills(roots: &MigrationRoots) -> LegacyMigrationResult "legacy Skill {source_id} failed current owner parsing: {error}" )) })?; - let (files, skipped_paths) = declared_skill_files(&source)?; + let mut files = Vec::new(); + collect_regular_files(&source, &source, &mut files)?; + enforce_tree_limits(&source, &files)?; let content_hash = hash_file_set(&source, &files)?; let target = target_root.join(&source_id); let (target_id, action) = resolve_directory_conflict(&source_id, &content_hash, &target)?; @@ -524,7 +489,7 @@ fn plan_skills(roots: &MigrationRoots) -> LegacyMigrationResult content_hash, source_path: source, files, - skipped_paths, + skipped_paths: Vec::new(), }); } Ok(planned) @@ -560,12 +525,14 @@ fn plan_miniapps(roots: &MigrationRoots) -> LegacyMigrationResult LegacyMigrationResult LegacyMigrationResult LegacyMigrationResult LegacyMigrationResult LegacyMigrationResult ( - source_id.clone(), - ImportAction::Duplicate, - parsed.definition, - ), + Some(existing) if existing == &source_bytes => { + (source_id.clone(), ImportAction::Duplicate, source_bytes) + } Some(_) => { - let target_id = remapped_id(&source_id, &content_hash); - let mut definition = parsed.definition; - definition.id = target_id.clone(); - definition.name = format!("{} (from legacy product)", definition.name); - (target_id, ImportAction::Remap, definition) + let target_id = remapped_id(&source_id, &source_hash); + let remapped = rewrite_agent_id(&source_bytes, &target_id)?; + (target_id, ImportAction::Remap, remapped) } - None => (source_id.clone(), ImportAction::Import, parsed.definition), + None => (source_id.clone(), ImportAction::Import, source_bytes), }; - definition.level = CustomAgentLevel::User; + let content_hash = hash_bytes(&staged_content); planned.push(PlannedAgent { source_id, target_id, action, content_hash, source_path, - definition, + staged_content, }); } Ok(planned) } -fn prepare_miniapp_import_input(item: &PlannedTree, input: &Path) -> LegacyMigrationResult<()> { - fs::create_dir_all(input.join(SOURCE_DIR)).map_err(|error| io(input, error))?; - let meta = fs::read(&item.source_path.join(META_JSON)) - .map_err(|error| io(&item.source_path.join(META_JSON), error))?; - atomic_write_bytes(&input.join(META_JSON), &meta)?; +fn write_miniapp_output( + source: &Path, + target_id: &str, + output: &Path, +) -> LegacyMigrationResult<()> { + for (relative, file) in miniapp_output_plan(source, target_id)? { + let target = output.join(relative); + match file { + PlannedMiniAppFile::Source(source) => copy_regular_file(&source, &target)?, + PlannedMiniAppFile::Generated(bytes) => atomic_write_bytes(&target, &bytes)?, + } + } + Ok(()) +} + +fn miniapp_output_plan( + source: &Path, + target_id: &str, +) -> LegacyMigrationResult> { + let raw_meta = fs::read_to_string(source.join(META_JSON)) + .map_err(|error| io(&source.join(META_JSON), error))?; + let owner_plan = build_import_bundle_plan(target_id, &raw_meta, 0).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "legacy MiniApp {target_id} failed current owner conversion: {error}" + )) + })?; + let mut files = BTreeMap::new(); + files.insert( + PathBuf::from(META_JSON), + PlannedMiniAppFile::Generated(merge_miniapp_meta(&raw_meta, &owner_plan.meta_json)?), + ); + files.insert( + PathBuf::from(COMPILED_HTML), + PlannedMiniAppFile::Generated(owner_plan.compiled_html.into_bytes()), + ); + for name in REQUIRED_SOURCE_FILES { - let source = item.source_path.join(name); - let target = input.join(SOURCE_DIR).join(name); - if source.exists() { - let bytes = fs::read(&source).map_err(|error| io(&source, error))?; - atomic_write_bytes(&target, &bytes)?; + let destination = PathBuf::from(SOURCE_DIR).join(name); + if let Some(path) = + first_regular_file(&[source.join(SOURCE_DIR).join(name), source.join(name)]) + { + files.insert(destination, PlannedMiniAppFile::Source(path)); } else { - atomic_write_bytes(&target, b"")?; + files.insert(destination, PlannedMiniAppFile::Generated(Vec::new())); } } - for name in [ESM_DEPS_JSON, PACKAGE_JSON, STORAGE_JSON] { - let source = item.source_path.join(name); - if source.exists() { - let bytes = fs::read(&source).map_err(|error| io(&source, error))?; - let target = if name == ESM_DEPS_JSON { - input.join(SOURCE_DIR).join(name) - } else { - input.join(name) - }; - atomic_write_bytes(&target, &bytes)?; + + let esm_destination = PathBuf::from(SOURCE_DIR).join(ESM_DEPS_JSON); + if let Some(path) = first_regular_file(&[ + source.join(SOURCE_DIR).join(ESM_DEPS_JSON), + source.join(ESM_DEPS_JSON), + ]) { + files.insert(esm_destination, PlannedMiniAppFile::Source(path)); + } else { + files.insert( + esm_destination, + PlannedMiniAppFile::Generated(owner_plan.esm_dependencies_json.into_bytes()), + ); + } + + for (name, fallback) in [ + (PACKAGE_JSON, owner_plan.package_json.into_bytes()), + (STORAGE_JSON, owner_plan.storage_json.into_bytes()), + ] { + let path = source.join(name); + if path.is_file() { + files.insert(PathBuf::from(name), PlannedMiniAppFile::Source(path)); + } else { + files.insert(PathBuf::from(name), PlannedMiniAppFile::Generated(fallback)); } } - Ok(()) + + let mut source_files = Vec::new(); + collect_regular_files(source, source, &mut source_files)?; + enforce_tree_limits(source, &source_files)?; + for path in source_files { + let relative = path + .strip_prefix(source) + .map_err(|_| LegacyMigrationError::PathEscape(path.clone()))? + .to_path_buf(); + if miniapp_owner_path(&relative) { + continue; + } + files + .entry(relative) + .or_insert(PlannedMiniAppFile::Source(path)); + } + Ok(files) +} + +fn miniapp_owner_path(relative: &Path) -> bool { + if relative == Path::new(META_JSON) + || relative == Path::new(COMPILED_HTML) + || relative == Path::new(PACKAGE_JSON) + || relative == Path::new(STORAGE_JSON) + || relative == Path::new(BUILTIN_INSTALL_MARKER) + || relative == Path::new(ESM_DEPS_JSON) + || REQUIRED_SOURCE_FILES + .iter() + .any(|name| relative == Path::new(name)) + { + return true; + } + REQUIRED_SOURCE_FILES + .iter() + .chain(std::iter::once(&ESM_DEPS_JSON)) + .any(|name| relative == Path::new(SOURCE_DIR).join(name)) +} + +fn first_regular_file(candidates: &[PathBuf]) -> Option { + candidates.iter().find(|path| path.is_file()).cloned() +} + +fn merge_miniapp_meta(raw: &str, normalized: &str) -> LegacyMigrationResult> { + let mut raw_value: serde_json::Value = serde_json::from_str(raw).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("invalid legacy MiniApp meta: {error}")) + })?; + let normalized_value: serde_json::Value = + serde_json::from_str(normalized).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "invalid normalized MiniApp meta: {error}" + )) + })?; + overlay_current_fields(&mut raw_value, &normalized_value); + serde_json::to_vec_pretty(&raw_value).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "failed to serialize normalized MiniApp meta: {error}" + )) + }) +} + +fn overlay_current_fields(target: &mut serde_json::Value, current: &serde_json::Value) { + match (target, current) { + (serde_json::Value::Object(target), serde_json::Value::Object(current)) => { + for (name, current_value) in current { + if let Some(target_value) = target.get_mut(name) { + overlay_current_fields(target_value, current_value); + } else { + target.insert(name.clone(), current_value.clone()); + } + } + } + (target, current) => *target = current.clone(), + } +} + +fn rewrite_agent_id(content: &[u8], target_id: &str) -> LegacyMigrationResult> { + let text = std::str::from_utf8(content).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!("legacy Agent is not UTF-8: {error}")) + })?; + let mut output = String::with_capacity(text.len() + target_id.len()); + let mut in_frontmatter = false; + let mut replaced = false; + for (index, line) in text.split_inclusive('\n').enumerate() { + let newline = if line.ends_with("\r\n") { + "\r\n" + } else if line.ends_with('\n') { + "\n" + } else { + "" + }; + let content = line.trim_end_matches(['\r', '\n']); + if index == 0 && content.trim() == "---" { + in_frontmatter = true; + output.push_str(line); + continue; + } + if in_frontmatter && content.trim() == "---" { + in_frontmatter = false; + } + if in_frontmatter && !replaced { + let trimmed = content.trim_start(); + if trimmed.starts_with("id:") { + let indentation = &content[..content.len() - trimmed.len()]; + output.push_str(indentation); + output.push_str("id: "); + output.push_str(target_id); + output.push_str(newline); + replaced = true; + continue; + } + } + output.push_str(line); + } + if !replaced { + return Err(LegacyMigrationError::InvalidRequest( + "legacy Agent frontmatter does not contain an id field".to_string(), + )); + } + let parsed = + custom_agent_read_markdown_str(&output, CustomAgentLevel::User).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "remapped Agent failed current owner parsing: {error}" + )) + })?; + if parsed.definition.id != target_id { + return Err(LegacyMigrationError::InvalidRequest( + "remapped Agent id was not applied".to_string(), + )); + } + Ok(output.into_bytes()) } fn validate_agent_manifest(context: &DomainContext<'_>, staged: bool) -> LegacyMigrationResult<()> { @@ -747,9 +887,8 @@ fn scan_from_trees( .sum(), source_schema: Some(source_schema.to_string()), migratable: true, - detail: - "Only owner-approved files are staged; imported executable content remains inert." - .to_string(), + detail: "Complete user extension trees are staged; only required current-format fields are normalized." + .to_string(), }, conflicts: planned .iter() @@ -941,59 +1080,6 @@ fn direct_markdown_files(root: &Path) -> LegacyMigrationResult> { Ok(output) } -fn declared_skill_files(root: &Path) -> LegacyMigrationResult<(Vec, Vec)> { - let mut files = Vec::new(); - let mut skipped = Vec::new(); - for entry in fs::read_dir(root).map_err(|error| io(root, error))? { - let entry = entry.map_err(|error| io(root, error))?; - let path = entry.path(); - reject_link(&path)?; - let name = entry.file_name().to_string_lossy().to_string(); - let kind = entry.file_type().map_err(|error| io(&path, error))?; - if kind.is_file() && name == "SKILL.md" { - files.push(path); - } else if kind.is_dir() && SKILL_ALLOWED_DIRECTORIES.contains(&name.as_str()) { - collect_regular_files(root, &path, &mut files)?; - } else { - skipped.push(name); - } - } - enforce_tree_limits(root, &files)?; - if !files.iter().any(|path| path == &root.join("SKILL.md")) { - return Err(LegacyMigrationError::InvalidRequest(format!( - "Skill {} does not contain SKILL.md", - root.display() - ))); - } - Ok((files, skipped)) -} - -fn declared_flat_files( - root: &Path, - allowed: &[&str], -) -> LegacyMigrationResult<(Vec, Vec)> { - let mut files = Vec::new(); - let mut skipped = Vec::new(); - for entry in fs::read_dir(root).map_err(|error| io(root, error))? { - let entry = entry.map_err(|error| io(root, error))?; - let path = entry.path(); - reject_link(&path)?; - let name = entry.file_name().to_string_lossy().to_string(); - if entry - .file_type() - .map_err(|error| io(&path, error))? - .is_file() - && allowed.contains(&name.as_str()) - { - files.push(path); - } else { - skipped.push(name); - } - } - enforce_tree_limits(root, &files)?; - Ok((files, skipped)) -} - fn collect_regular_files( declared_root: &Path, current: &Path, @@ -1030,12 +1116,6 @@ fn enforce_tree_limits(root: &Path, files: &[PathBuf]) -> LegacyMigrationResult< let mut total = 0u64; for path in files { let bytes = fs::metadata(path).map_err(|error| io(path, error))?.len(); - if bytes > MAX_FILE_BYTES { - return Err(LegacyMigrationError::ResourceLimit(format!( - "extension file exceeds {MAX_FILE_BYTES} bytes: {}", - path.display() - ))); - } total = total.saturating_add(bytes); } if total > MAX_EXTENSION_BYTES { @@ -1057,12 +1137,30 @@ fn copy_declared_tree( .strip_prefix(source_root) .map_err(|_| LegacyMigrationError::PathEscape(source.to_path_buf()))?; let target = target_root.join(relative); - let bytes = fs::read(source).map_err(|error| io(source, error))?; - atomic_write_bytes(&target, &bytes)?; + copy_regular_file(source, &target)?; } Ok(()) } +fn copy_regular_file(source: &Path, target: &Path) -> LegacyMigrationResult<()> { + reject_link(source)?; + if !source.is_file() { + return Err(LegacyMigrationError::InvalidRequest(format!( + "expected a regular extension file: {}", + source.display() + ))); + } + let parent = target.parent().ok_or_else(|| { + LegacyMigrationError::InvalidRequest(format!( + "extension target has no parent: {}", + target.display() + )) + })?; + fs::create_dir_all(parent).map_err(|error| io(parent, error))?; + fs::copy(source, target).map_err(|error| io(target, error))?; + Ok(()) +} + fn copy_directory(source: &Path, target: &Path) -> LegacyMigrationResult<()> { let mut files = Vec::new(); collect_regular_files(source, source, &mut files)?; @@ -1092,28 +1190,30 @@ fn resolve_miniapp_conflict( source_id: &str, source_hash: &str, target_root: &Path, -) -> LegacyMigrationResult<(String, ImportAction)> { +) -> LegacyMigrationResult<(String, ImportAction, String)> { + let content_hash = hash_miniapp_output(source, source_id)?; let target = target_root.join(source_id); if !target.exists() { - return Ok((source_id.to_string(), ImportAction::Import)); + return Ok((source_id.to_string(), ImportAction::Import, content_hash)); } reject_link(&target)?; - if current_miniapp_matches_legacy(source, &target, source_id)? { - return Ok((source_id.to_string(), ImportAction::Duplicate)); + if hash_tree(&target)? == content_hash { + return Ok((source_id.to_string(), ImportAction::Duplicate, content_hash)); } let remapped = remapped_id(source_id, source_hash); + let remapped_hash = hash_miniapp_output(source, &remapped)?; let remapped_target = target_root.join(&remapped); if remapped_target.exists() { reject_link(&remapped_target)?; - if current_miniapp_matches_legacy(source, &remapped_target, &remapped)? { - return Ok((remapped, ImportAction::Duplicate)); + if hash_tree(&remapped_target)? == remapped_hash { + return Ok((remapped, ImportAction::Duplicate, remapped_hash)); } return Err(LegacyMigrationError::InvalidRequest(format!( "remapped MiniApp target already contains different data: {}", remapped_target.display() ))); } - Ok((remapped, ImportAction::Remap)) + Ok((remapped, ImportAction::Remap, remapped_hash)) } fn resolve_remapped_miniapp_conflict( @@ -1121,15 +1221,16 @@ fn resolve_remapped_miniapp_conflict( source_id: &str, source_hash: &str, target_root: &Path, -) -> LegacyMigrationResult<(String, ImportAction)> { +) -> LegacyMigrationResult<(String, ImportAction, String)> { let remapped = remapped_id(source_id, source_hash); + let content_hash = hash_miniapp_output(source, &remapped)?; let target = target_root.join(&remapped); if !target.exists() { - return Ok((remapped, ImportAction::Remap)); + return Ok((remapped, ImportAction::Remap, content_hash)); } reject_link(&target)?; - if current_miniapp_matches_legacy(source, &target, &remapped)? { - Ok((remapped, ImportAction::Duplicate)) + if hash_tree(&target)? == content_hash { + Ok((remapped, ImportAction::Duplicate, content_hash)) } else { Err(LegacyMigrationError::InvalidRequest(format!( "remapped MiniApp target already contains different data: {}", @@ -1138,70 +1239,8 @@ fn resolve_remapped_miniapp_conflict( } } -fn current_miniapp_matches_legacy( - source: &Path, - target: &Path, - target_id: &str, -) -> LegacyMigrationResult { - let source_meta = fs::read_to_string(source.join(META_JSON)) - .map_err(|error| io(&source.join(META_JSON), error))?; - let plan = build_import_bundle_plan(target_id, &source_meta, 0).map_err(|error| { - LegacyMigrationError::InvalidRequest(format!( - "legacy MiniApp {target_id} failed current owner conversion: {error}" - )) - })?; - let expected_meta: serde_json::Value = - serde_json::from_str(&plan.meta_json).map_err(|error| { - LegacyMigrationError::InvalidRequest(format!("invalid converted MiniApp meta: {error}")) - })?; - let actual_meta: serde_json::Value = match read_bounded_json(target, &target.join(META_JSON)) { - Ok(value) => value, - Err(_) => return Ok(false), - }; - if actual_meta != expected_meta { - return Ok(false); - } - for name in REQUIRED_SOURCE_FILES { - let source_path = source.join(name); - let expected = if source_path.exists() { - fs::read(&source_path).map_err(|error| io(&source_path, error))? - } else { - Vec::new() - }; - let target_path = target.join(SOURCE_DIR).join(name); - if fs::read(&target_path).ok().as_deref() != Some(expected.as_slice()) { - return Ok(false); - } - } - let expected_esm = optional_bytes_or( - &source.join(ESM_DEPS_JSON), - plan.esm_dependencies_json.as_bytes(), - )?; - if fs::read(target.join(SOURCE_DIR).join(ESM_DEPS_JSON)) - .ok() - .as_deref() - != Some(expected_esm.as_slice()) - { - return Ok(false); - } - for (name, fallback) in [ - (PACKAGE_JSON, plan.package_json.as_bytes()), - (STORAGE_JSON, plan.storage_json.as_bytes()), - ] { - let expected = optional_bytes_or(&source.join(name), fallback)?; - if fs::read(target.join(name)).ok().as_deref() != Some(expected.as_slice()) { - return Ok(false); - } - } - Ok(fs::read(target.join(COMPILED_HTML)).ok().as_deref() == Some(plan.compiled_html.as_bytes())) -} - -fn optional_bytes_or(path: &Path, fallback: &[u8]) -> LegacyMigrationResult> { - match fs::read(path) { - Ok(bytes) => Ok(bytes), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(fallback.to_vec()), - Err(error) => Err(io(path, error)), - } +fn hash_miniapp_output(source: &Path, target_id: &str) -> LegacyMigrationResult { + hash_planned_files(&miniapp_output_plan(source, target_id)?) } fn install_directory_idempotent( @@ -1266,15 +1305,7 @@ fn rollback_imported_directories( for entry in imported_entries(&manifest) { let target = target_root.join(&entry.target_id); if target.exists() { - let expected = if name == "miniapps" { - let staged = stage_domain_dir(context, name) - .join("output") - .join(&entry.target_id); - hash_declared_current_miniapp(&staged)? - } else { - entry.content_hash.clone() - }; - if hash_tree(&target)? == expected { + if hash_tree(&target)? == entry.content_hash { fs::remove_dir_all(&target).map_err(|error| io(&target, error))?; } } @@ -1322,27 +1353,6 @@ fn require_hash(root: &Path, expected: &str) -> LegacyMigrationResult<()> { Ok(()) } -fn hash_declared_current_miniapp(root: &Path) -> LegacyMigrationResult { - let layout = MiniAppStorageLayout::new(root.parent().unwrap_or(root), file_name(root)?); - let mut files = vec![ - layout.meta_path(), - layout.package_json_path(), - layout.storage_path(), - layout.compiled_path(), - ]; - files.extend( - REQUIRED_SOURCE_FILES - .iter() - .map(|name| layout.source_file_path(name)), - ); - files.push(layout.source_file_path(ESM_DEPS_JSON)); - let files = files - .into_iter() - .filter(|path| path.exists()) - .collect::>(); - hash_file_set(root, &files) -} - fn hash_tree(root: &Path) -> LegacyMigrationResult { let mut files = Vec::new(); collect_regular_files(root, root, &mut files)?; @@ -1364,16 +1374,46 @@ fn hash_file_set(root: &Path, files: &[PathBuf]) -> LegacyMigrationResult LegacyMigrationResult { - fs::read(path) - .map(|bytes| hash_bytes(&bytes)) - .map_err(|error| io(path, error)) + let mut hasher = Sha256::new(); + hash_file_contents(path, &mut hasher)?; + Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) +} + +fn hash_planned_files( + files: &BTreeMap, +) -> LegacyMigrationResult { + let mut hasher = Sha256::new(); + for (relative, file) in files { + hasher.update(relative.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update([0]); + match file { + PlannedMiniAppFile::Source(path) => hash_file_contents(path, &mut hasher)?, + PlannedMiniAppFile::Generated(bytes) => hasher.update(bytes), + } + hasher.update([0]); + } + Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) +} + +fn hash_file_contents(path: &Path, hasher: &mut Sha256) -> LegacyMigrationResult<()> { + let file = fs::File::open(path).map_err(|error| io(path, error))?; + let mut reader = BufReader::new(file); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = reader.read(&mut buffer).map_err(|error| io(path, error))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(()) } fn hash_bytes(bytes: &[u8]) -> String { @@ -1493,7 +1533,7 @@ mod tests { use std::collections::BTreeSet; #[test] - fn extension_group_uses_owner_formats_and_excludes_builtins() { + fn extension_group_preserves_user_trees_and_excludes_builtin_code() { let temp = test_tempdir("extensions"); let roots = fixture_roots(temp.path()); copy_fixture(&roots); @@ -1501,7 +1541,32 @@ mod tests { .legacy_skills_root .join("user-skill") .join("undeclared.txt"); - atomic_write_bytes(&unknown, b"private fixture content that must stay excluded").unwrap(); + atomic_write_bytes(&unknown, b"private fixture content that must be preserved").unwrap(); + let skill_readme = roots.legacy_skills_root.join("user-skill/README.md"); + atomic_write_bytes(&skill_readme, b"User-maintained Skill notes.").unwrap(); + let skill_image = roots + .legacy_skills_root + .join("user-skill/assets/reference.bin"); + atomic_write_bytes(&skill_image, b"fixture-image").unwrap(); + + let source_agent = roots.legacy_user_root.join("agents/researcher.md"); + let agent_text = fs::read_to_string(&source_agent).unwrap().replace( + "schema_version: 1", + "schema_version: 1\nfuture_field: keep-me\n# user comment", + ); + atomic_write_bytes(&source_agent, agent_text.as_bytes()).unwrap(); + + let custom_miniapp = roots.legacy_user_root.join("data/miniapps/custom-notes"); + atomic_write_bytes( + &custom_miniapp.join("source/assets/icon.svg"), + b"preserved", + ) + .unwrap(); + atomic_write_bytes(&custom_miniapp.join("assets/legacy.bin"), b"legacy-asset").unwrap(); + let mut meta_value: serde_json::Value = + serde_json::from_slice(&fs::read(custom_miniapp.join(META_JSON)).unwrap()).unwrap(); + meta_value["futureField"] = serde_json::json!({"nested": true}); + atomic_write_json(&custom_miniapp.join(META_JSON), &meta_value).unwrap(); let source_hashes = [ hash_tree(&roots.legacy_user_root).unwrap(), hash_tree(&roots.legacy_home_root).unwrap(), @@ -1529,11 +1594,19 @@ mod tests { .target_skills_root .join(OPENBITFUN_SYSTEM_SKILL_DIR) .exists()); - assert!(!roots + assert!(roots .target_skills_root .join("user-skill/undeclared.txt") .exists()); - assert!(report + assert!(roots + .target_skills_root + .join("user-skill/README.md") + .exists()); + assert!(roots + .target_skills_root + .join("user-skill/assets/reference.bin") + .exists()); + assert!(!report .domain_results .iter() .flat_map(|result| &result.warnings) @@ -1543,6 +1616,17 @@ mod tests { .contains("private fixture content")); let custom = target_miniapps_root(&roots).join("custom-notes"); assert!(custom.join("source/index.html").exists()); + assert_eq!( + fs::read(custom.join("source/assets/icon.svg")).unwrap(), + b"preserved" + ); + assert_eq!( + fs::read(custom.join("assets/legacy.bin")).unwrap(), + b"legacy-asset" + ); + let imported_meta: serde_json::Value = + serde_json::from_slice(&fs::read(custom.join(META_JSON)).unwrap()).unwrap(); + assert_eq!(imported_meta["futureField"]["nested"], true); assert!(custom.join(COMPILED_HTML).exists()); assert!(!target_miniapps_root(&roots) .join("builtin-gomoku/index.html") @@ -1552,11 +1636,15 @@ mod tests { .exists()); let agent_path = roots.target_user_root.join("agents/researcher.md"); let agent = custom_agent_read_markdown_str( - &fs::read_to_string(agent_path).unwrap(), + &fs::read_to_string(&agent_path).unwrap(), CustomAgentLevel::User, ) .unwrap(); assert_eq!(agent.definition.id, "researcher"); + assert_eq!( + fs::read(&agent_path).unwrap(), + fs::read(&source_agent).unwrap() + ); let second_source = probe_legacy_source(&roots, ProbeLimits::default()) .unwrap() @@ -1568,6 +1656,10 @@ mod tests { .conflicts .iter() .any(|conflict| conflict.resolution == ConflictResolution::TargetWins)); + assert!(second + .conflicts + .iter() + .any(|conflict| conflict.resolution == ConflictResolution::DuplicateSkipped)); engine .execute(&second, &CancellationToken::default(), &NoCrashInjection) .unwrap(); @@ -1684,6 +1776,16 @@ mod tests { assert_eq!(fs::read(&target).unwrap(), br#"{"value":"target"}"#); } + #[test] + fn remapping_agent_changes_only_the_frontmatter_id_line() { + let source = b"---\r\nid: researcher\r\nname: Researcher\r\ndescription: Test agent\r\nfuture_field: keep-me\r\n# user comment\r\nkind: subagent\r\ntools: []\r\nreadonly: true\r\nschema_version: 1\r\n---\r\n\r\nKeep this body byte-for-byte.\r\n"; + let remapped = rewrite_agent_id(source, "researcher-from-legacy-deadbeef").unwrap(); + let text = String::from_utf8(remapped).unwrap(); + assert!(text.contains("id: researcher-from-legacy-deadbeef\r\n")); + assert!(text.contains("future_field: keep-me\r\n# user comment\r\n")); + assert!(text.ends_with("Keep this body byte-for-byte.\r\n")); + } + fn fixture_roots(root: &Path) -> MigrationRoots { let legacy_user_root = root.join("legacy-user"); MigrationRoots { diff --git a/src/crates/assembly/core/src/legacy_migration/settings.rs b/src/crates/assembly/core/src/legacy_migration/settings.rs index ef6070c835..f85223a0ae 100644 --- a/src/crates/assembly/core/src/legacy_migration/settings.rs +++ b/src/crates/assembly/core/src/legacy_migration/settings.rs @@ -3,7 +3,7 @@ use super::common::{ restore_unverified_file, stage_domain_dir, }; use crate::service::config::manager::validate_current_config_value; -use crate::service::config::types::{AIModelConfig, AgentProfileConfig, GlobalConfig}; +use crate::service::config::types::{AIModelConfig, GlobalConfig}; use openbitfun_legacy_migration::{ atomic_write_json, DomainContext, DomainScan, LegacyDomainAdapter, LegacyMigrationError, LegacyMigrationResult, MigrationRoots, @@ -40,6 +40,7 @@ struct CredentialManifest { target_existed: bool, model_ids: Vec, voice_call: bool, + mcp_servers: bool, unsupported_secret_fields: Vec, } @@ -159,7 +160,9 @@ impl LegacyDomainAdapter for CredentialsAdapter { fn scan(&self, roots: &MigrationRoots) -> LegacyMigrationResult { let source = read_source_config(roots)?; let manifest = credential_manifest(&source, target_config_path(roots).exists()); - let count = manifest.model_ids.len() as u64 + u64::from(manifest.voice_call); + let count = manifest.model_ids.len() as u64 + + u64::from(manifest.voice_call) + + u64::from(manifest.mcp_servers); Ok(DomainScan { finding: ScanFinding { domain: self.domain(), @@ -206,7 +209,9 @@ impl LegacyDomainAdapter for CredentialsAdapter { Ok(MigrationDomainResult { domain: self.domain(), state: MigrationDomainState::Staged, - imported: manifest.model_ids.len() as u64 + u64::from(manifest.voice_call), + imported: manifest.model_ids.len() as u64 + + u64::from(manifest.voice_call) + + u64::from(manifest.mcp_servers), skipped: manifest.unsupported_secret_fields.len() as u64, warnings, requires_reauthentication: manifest.unsupported_secret_fields.clone(), @@ -260,13 +265,30 @@ impl LegacyDomainAdapter for CredentialsAdapter { if !manifest.model_ids.iter().any(|candidate| candidate == id) { continue; } - let Some(secret) = source_model.get("api_key").and_then(Value::as_str) else { - continue; - }; + let source_model = serde_json::from_value::(source_model.clone()) + .map_err(|error| { + LegacyMigrationError::UnsupportedSource(format!( + "legacy model credential owner record is invalid: {error}" + )) + })?; if let Some(target_model) = target.ai.models.iter_mut().find(|model| model.id == id) { if target_model.api_key.is_empty() { - target_model.api_key = secret.to_string(); + target_model.api_key = source_model.api_key; + } + if target_model + .custom_headers + .as_ref() + .is_none_or(|headers| headers.is_empty()) + { + target_model.custom_headers = source_model.custom_headers; + } + if target_model + .custom_request_body + .as_deref() + .is_none_or(str::is_empty) + { + target_model.custom_request_body = source_model.custom_request_body; } } } @@ -279,6 +301,12 @@ impl LegacyDomainAdapter for CredentialsAdapter { target.app.voice_call.api_key = secret.to_string(); } } + if manifest.mcp_servers && target.mcp_servers.is_none() { + target.mcp_servers = source + .get("mcp_servers") + .filter(|value| !value.is_null()) + .cloned(); + } validate_current_config(&target, "credential migration target")?; atomic_write_json(&target_path, &target) } @@ -292,7 +320,7 @@ impl LegacyDomainAdapter for CredentialsAdapter { )?; validate_current_config(&target, "committed credential configuration")?; for id in &manifest.model_ids { - let source_secret = source + let source_model = source .pointer("/ai/models") .and_then(Value::as_array) .and_then(|models| { @@ -300,21 +328,59 @@ impl LegacyDomainAdapter for CredentialsAdapter { .iter() .find(|model| model.get("id").and_then(Value::as_str) == Some(id.as_str())) }) - .and_then(|model| model.get("api_key")) - .and_then(Value::as_str); - let target_secret = target - .ai - .models - .iter() - .find(|model| model.id == *id) - .map(|model| model.api_key.as_str()); - if source_secret.is_some_and(|secret| !secret.is_empty()) - && target_secret.is_none_or(str::is_empty) - { + .cloned() + .map(serde_json::from_value::) + .transpose() + .map_err(|error| { + LegacyMigrationError::UnsupportedSource(format!( + "legacy model credential owner record is invalid: {error}" + )) + })?; + let target_model = target.ai.models.iter().find(|model| model.id == *id); + let Some(source_model) = source_model else { + continue; + }; + let Some(target_model) = target_model else { + return Err(LegacyMigrationError::InvalidRequest(format!( + "credential owner model {id} was not committed" + ))); + }; + if !source_model.api_key.is_empty() && target_model.api_key.is_empty() { return Err(LegacyMigrationError::InvalidRequest(format!( "credential for model {id} was not committed" ))); } + if source_model + .custom_headers + .as_ref() + .is_some_and(|headers| !headers.is_empty()) + && target_model + .custom_headers + .as_ref() + .is_none_or(|headers| headers.is_empty()) + { + return Err(LegacyMigrationError::InvalidRequest(format!( + "custom headers for model {id} were not committed" + ))); + } + if source_model + .custom_request_body + .as_deref() + .is_some_and(|body| !body.is_empty()) + && target_model + .custom_request_body + .as_deref() + .is_none_or(str::is_empty) + { + return Err(LegacyMigrationError::InvalidRequest(format!( + "custom request body for model {id} was not committed" + ))); + } + } + if manifest.mcp_servers && target.mcp_servers.is_none() { + return Err(LegacyMigrationError::InvalidRequest( + "MCP server configuration was not committed".to_string(), + )); } Ok(()) } @@ -358,118 +424,278 @@ fn read_target_config(roots: &MigrationRoots) -> LegacyMigrationResult LegacyMigrationResult<(GlobalConfig, MergeOutcome)> { - validate_source_version(source)?; + let (mut source_config, mut source_value) = convert_source_config(source)?; let defaults = GlobalConfig::default(); let mut outcome = MergeOutcome::default(); - - merge_scalar( - source.pointer("/app/language").and_then(Value::as_str), - &mut target.app.language, - &defaults.app.language, - "app.language", + let source_models = std::mem::take(&mut source_config.ai.models); + let mut target_value = serde_json::to_value(&target) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + let default_value = serde_json::to_value(&defaults) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + if let Some(ai) = source_value.get_mut("ai").and_then(Value::as_object_mut) { + ai.remove("models"); + } + merge_compatible_value( + "", + &source_value, + &mut target_value, + Some(&default_value), &mut outcome, ); - merge_copy_scalar( - source.pointer("/app/auto_update").and_then(Value::as_bool), - &mut target.app.auto_update, - defaults.app.auto_update, - "app.auto_update", - &mut outcome, + let mut merged: GlobalConfig = serde_json::from_value(target_value).map_err(|error| { + LegacyMigrationError::InvalidRequest(format!( + "merged configuration does not match the current owner model: {error}" + )) + })?; + merge_models(source_models, &mut merged.ai.models, &mut outcome)?; + merged.product_id = defaults.product_id; + merged.schema_version = defaults.schema_version; + merged.version = defaults.version; + merged.last_modified = chrono::Utc::now(); + Ok((merged, outcome)) +} + +fn convert_source_config(source: &Value) -> LegacyMigrationResult<(GlobalConfig, Value)> { + validate_source_version(source)?; + let defaults = GlobalConfig::default(); + let mut normalized_source = source.clone(); + normalize_legacy_config_value(&mut normalized_source); + strip_staged_credentials(&mut normalized_source); + let normalized_root = normalized_source.as_object_mut().ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "legacy configuration root is not an object".to_string(), + ) + })?; + for field in ["product_id", "schema_version", "version", "last_modified"] { + normalized_root.remove(field); + } + + let mut converted = serde_json::to_value(&defaults) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + overlay_compatible_value(&mut converted, &normalized_source); + let root = converted.as_object_mut().ok_or_else(|| { + LegacyMigrationError::InvalidRequest( + "legacy configuration root is not an object".to_string(), + ) + })?; + root.insert( + "product_id".to_string(), + Value::String(defaults.product_id.clone()), ); - merge_copy_scalar( - source.pointer("/app/telemetry").and_then(Value::as_bool), - &mut target.app.telemetry, - defaults.app.telemetry, - "app.telemetry", - &mut outcome, + root.insert( + "schema_version".to_string(), + Value::from(defaults.schema_version), ); - if let Some(theme) = source - .pointer("/appearance/theme_id") - .and_then(Value::as_str) - { - let theme = canonical_product_id(theme); - merge_scalar( - Some(&theme), - &mut target.appearance.selection, - &defaults.appearance.selection, - "appearance.selection", - &mut outcome, - ); + root.insert( + "version".to_string(), + Value::String(defaults.version.clone()), + ); + root.insert( + "last_modified".to_string(), + Value::from(chrono::Utc::now().timestamp_millis()), + ); + let config: GlobalConfig = serde_json::from_value(converted).map_err(|error| { + LegacyMigrationError::UnsupportedSource(format!( + "legacy configuration cannot be represented by the current owner model: {error}" + )) + })?; + let mut compatible_source = serde_json::to_value(&config) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + retain_source_fields(&mut compatible_source, &normalized_source); + Ok((config, compatible_source)) +} + +fn overlay_compatible_value(target: &mut Value, source: &Value) { + match (target, source) { + (Value::Object(target_fields), Value::Object(source_fields)) => { + for (name, source_value) in source_fields { + if let Some(target_value) = target_fields.get_mut(name) { + overlay_compatible_value(target_value, source_value); + } else { + target_fields.insert(name.clone(), source_value.clone()); + } + } + } + (target, source) => *target = source.clone(), } +} - if let Some(profiles) = source - .pointer("/ai/agent_profiles") - .and_then(Value::as_object) - { +fn retain_source_fields(value: &mut Value, source: &Value) { + let (Value::Object(fields), Value::Object(source_fields)) = (value, source) else { + return; + }; + fields.retain(|name, value| { + let Some(source_value) = source_fields.get(name) else { + return false; + }; + retain_source_fields(value, source_value); + true + }); +} + +fn normalize_legacy_config_value(value: &mut Value) { + if let Some(appearance) = value.get_mut("appearance").and_then(Value::as_object_mut) { + let selection = appearance + .get("selection") + .or_else(|| appearance.get("theme_id")) + .and_then(Value::as_str) + .map(canonical_product_id); + if let Some(selection) = selection { + appearance.insert("selection".to_string(), Value::String(selection)); + } + appearance.remove("theme_id"); + } + + let Some(ai) = value.get_mut("ai").and_then(Value::as_object_mut) else { + return; + }; + if let Some(profiles) = ai.get_mut("agent_profiles").and_then(Value::as_object_mut) { for (profile_id, profile) in profiles { - let enabled = profile - .get("enabled_skills") - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .filter_map(Value::as_str) - .map(canonical_product_id) - .collect::>() - }) - .unwrap_or_default(); - if enabled.is_empty() { + let Some(profile) = profile.as_object_mut() else { continue; + }; + if profile + .get("profile_id") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + { + profile.insert("profile_id".to_string(), Value::String(profile_id.clone())); } - let entry = target - .ai - .agent_profiles - .entry(profile_id.clone()) - .or_insert_with(|| AgentProfileConfig { - profile_id: profile_id.clone(), - ..AgentProfileConfig::default() - }); - if entry.enabled_user_skills.is_empty() { - entry.enabled_user_skills = enabled; - outcome.imported += 1; - } else { - record_target_wins( - &format!("ai.agent_profiles.{profile_id}.enabled_user_skills"), - &mut outcome, - ); + if let Some(enabled) = profile.remove("enabled_skills") { + profile + .entry("enabled_user_skills".to_string()) + .or_insert(enabled); } + canonicalize_string_list(profile.get_mut("enabled_user_skills")); + canonicalize_string_list(profile.get_mut("disabled_user_skills")); + } + } + if let Some(skill_settings) = ai.get_mut("skill_settings").and_then(Value::as_object_mut) { + canonicalize_string_list(skill_settings.get_mut("globally_disabled_user_skills")); + } +} + +fn canonicalize_string_list(value: Option<&mut Value>) { + let Some(values) = value.and_then(Value::as_array_mut) else { + return; + }; + for value in values { + if let Some(text) = value.as_str() { + *value = Value::String(canonical_product_id(text)); } } +} - if let Some(models) = source.pointer("/ai/models").and_then(Value::as_array) { - for raw in models { - let Ok(mut model) = serde_json::from_value::(raw.clone()) else { - outcome.skipped += 1; +fn strip_staged_credentials(value: &mut Value) { + if let Some(root) = value.as_object_mut() { + root.remove("mcp_servers"); + } + if let Some(models) = value + .pointer_mut("/ai/models") + .and_then(Value::as_array_mut) + { + for model in models { + let Some(model) = model.as_object_mut() else { continue; }; - if model.id.trim().is_empty() { - outcome.skipped += 1; - continue; - } - model.api_key.clear(); - model.custom_headers = None; - model.custom_request_body = None; - if target - .ai - .models - .iter() - .any(|existing| existing.id == model.id) - { - record_target_wins(&format!("ai.models.{}", model.id), &mut outcome); + model.remove("api_key"); + model.remove("custom_headers"); + model.remove("custom_request_body"); + } + } + if let Some(voice_call) = value + .pointer_mut("/app/voice_call") + .and_then(Value::as_object_mut) + { + voice_call.remove("api_key"); + } +} + +fn merge_compatible_value( + path: &str, + source: &Value, + target: &mut Value, + default: Option<&Value>, + outcome: &mut MergeOutcome, +) { + if source == target { + return; + } + if let (Value::Object(source_fields), Value::Object(target_fields)) = (source, &mut *target) { + let default_fields = default.and_then(Value::as_object); + for (name, source_value) in source_fields { + let child_path = if path.is_empty() { + name.clone() } else { - target.ai.models.push(model); - outcome.imported += 1; + format!("{path}.{name}") + }; + if let Some(target_value) = target_fields.get_mut(name) { + merge_compatible_value( + &child_path, + source_value, + target_value, + default_fields.and_then(|fields| fields.get(name)), + outcome, + ); + } else { + target_fields.insert(name.clone(), source_value.clone()); + outcome.imported = outcome.imported.saturating_add(1); + } + } + return; + } + + if default.is_some_and(|default| target == default) { + *target = source.clone(); + outcome.imported = outcome.imported.saturating_add(1); + } else { + record_target_wins(path, outcome); + } +} + +fn merge_models( + source_models: Vec, + target_models: &mut Vec, + outcome: &mut MergeOutcome, +) -> LegacyMigrationResult<()> { + for model in source_models { + if model.id.trim().is_empty() { + outcome.skipped = outcome.skipped.saturating_add(1); + continue; + } + if let Some(existing) = target_models + .iter() + .find(|existing| existing.id == model.id) + { + let existing = settings_only_model_value(existing)?; + let source = settings_only_model_value(&model)?; + if existing != source { + record_target_wins(&format!("ai.models.{}", model.id), outcome); } + } else { + target_models.push(model); + outcome.imported = outcome.imported.saturating_add(1); } } + Ok(()) +} + +fn settings_only_model_value(model: &AIModelConfig) -> LegacyMigrationResult { + let mut value = serde_json::to_value(model) + .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string()))?; + strip_model_credentials(&mut value); + Ok(value) +} - target.product_id = defaults.product_id; - target.schema_version = defaults.schema_version; - target.version = defaults.version; - target.last_modified = chrono::Utc::now(); - Ok((target, outcome)) +fn strip_model_credentials(value: &mut Value) { + let Some(model) = value.as_object_mut() else { + return; + }; + model.remove("api_key"); + model.remove("custom_headers"); + model.remove("custom_request_body"); } fn credential_manifest(source: &Value, target_existed: bool) -> CredentialManifest { @@ -478,12 +704,7 @@ fn credential_manifest(source: &Value, target_existed: bool) -> CredentialManife .and_then(Value::as_array) .into_iter() .flatten() - .filter(|model| { - model - .get("api_key") - .and_then(Value::as_str) - .is_some_and(|secret| !secret.is_empty()) - }) + .filter(|model| model_has_portable_credentials(model)) .filter_map(|model| model.get("id").and_then(Value::as_str)) .filter(|id| !id.trim().is_empty()) .map(str::to_string) @@ -494,22 +715,48 @@ fn credential_manifest(source: &Value, target_existed: bool) -> CredentialManife .pointer("/app/voice_call/api_key") .and_then(Value::as_str) .is_some_and(|secret| !secret.is_empty()); + let mcp_servers = source + .get("mcp_servers") + .is_some_and(|value| !value.is_null()); let mut unsupported_secret_fields = Vec::new(); collect_unsupported_secret_fields(source, "", &mut unsupported_secret_fields); - unsupported_secret_fields.retain(|path| { - path != "app.voice_call.api_key" - && !(path.starts_with("ai.models.") && path.ends_with(".api_key")) - }); + unsupported_secret_fields.retain(|path| !is_portable_credential_path(path)); unsupported_secret_fields.sort(); unsupported_secret_fields.dedup(); CredentialManifest { target_existed, model_ids, voice_call, + mcp_servers, unsupported_secret_fields, } } +fn model_has_portable_credentials(model: &Value) -> bool { + model + .get("api_key") + .and_then(Value::as_str) + .is_some_and(|secret| !secret.is_empty()) + || model + .get("custom_headers") + .and_then(Value::as_object) + .is_some_and(|headers| !headers.is_empty()) + || model + .get("custom_request_body") + .and_then(Value::as_str) + .is_some_and(|body| !body.is_empty()) +} + +fn is_portable_credential_path(path: &str) -> bool { + path == "app.voice_call.api_key" + || path == "mcp_servers" + || path.starts_with("mcp_servers.") + || (path.starts_with("ai.models.") + && (path.ends_with(".api_key") + || path.contains(".custom_headers.") + || path.ends_with(".custom_request_body"))) +} + fn collect_unsupported_secret_fields(value: &Value, path: &str, output: &mut Vec) { match value { Value::Object(fields) => { @@ -580,38 +827,6 @@ fn validate_current_config(config: &GlobalConfig, context: &str) -> LegacyMigrat .map_err(|error| LegacyMigrationError::InvalidRequest(error.to_string())) } -fn merge_scalar( - source: Option<&str>, - target: &mut String, - default: &str, - path: &str, - outcome: &mut MergeOutcome, -) { - let Some(source) = source else { return }; - if target == default { - *target = source.to_string(); - outcome.imported += 1; - } else { - record_target_wins(path, outcome); - } -} - -fn merge_copy_scalar( - source: Option, - target: &mut T, - default: T, - path: &str, - outcome: &mut MergeOutcome, -) { - let Some(source) = source else { return }; - if *target == default { - *target = source; - outcome.imported += 1; - } else { - record_target_wins(path, outcome); - } -} - fn record_target_wins(path: &str, outcome: &mut MergeOutcome) { outcome.skipped += 1; outcome.conflicts.push(MigrationConflict { @@ -686,14 +901,96 @@ mod tests { .unwrap(); let target = read_target_config(&roots).unwrap(); - assert_eq!(target.app.language, "zh-CN"); + assert_eq!(target.app.language, "en-US"); assert!(!target.app.auto_update); + assert!(target.app.telemetry); + assert!(!target.app.confirm_on_exit); + assert!(!target.app.restore_windows); + assert!(target.app.prevent_sleep); + assert_eq!(target.app.zoom_level, 1.25); + assert_eq!(target.app.notifications.duration, 8123); + assert_eq!(target.editor.font_size, 17); + assert_eq!(target.editor.word_wrap, "on"); + assert!(!target.editor.format_on_save); + assert_eq!(target.terminal.default_shell, "pwsh"); + assert_eq!(target.terminal.terminal_panel_position, "bottom"); + assert_eq!(target.terminal.font_size, 16); + assert_eq!(target.terminal.scrollback, 4321); + assert_eq!(target.workspace.max_file_size, 123_456); + assert_eq!(target.workspace.line_ending, "lf"); + assert!(!target.workspace.insert_final_newline); + assert!(target.tool_permissions.interaction.auto_approve_ask); assert_eq!(target.appearance.selection, "openbitfun-dark"); assert_eq!( - target.ai.agent_profiles["coding_shared"].enabled_user_skills, + target.ai.default_models.primary.as_deref(), + Some("legacy-model") + ); + assert_eq!( + target.ai.default_models.fast.as_deref(), + Some("legacy-model") + ); + assert_eq!(target.ai.agent_model_defaults.mode, "legacy-model"); + assert_eq!( + target + .ai + .agent_model_defaults + .subagents + .default_selection + .fixed_model_id(), + Some("legacy-model") + ); + let profile = &target.ai.agent_profiles["coding_shared"]; + assert_eq!(profile.profile_id, "coding_shared"); + assert_eq!(profile.added_tools, ["LegacyTool"]); + assert_eq!(profile.removed_tools, ["ReadFile"]); + assert_eq!( + profile.disabled_user_skills, + ["user::openbitfun::disabled-skill"] + ); + assert_eq!( + profile.enabled_user_skills, ["user::openbitfun::user-skill"] ); - assert_eq!(target.ai.models[0].api_key, "fixture-secret"); + assert_eq!( + target.ai.skill_settings.globally_disabled_user_skills, + ["user::openbitfun::global-disabled-skill"] + ); + assert_eq!( + target.ai.review_teams["default"].reviewer_timeout_seconds, + 77 + ); + assert_eq!(target.ai.subagent_max_concurrency, 3); + assert_eq!(target.ai.stream_idle_timeout_secs, Some(321)); + assert_eq!(target.ai.stream_ttft_timeout_secs, Some(123)); + assert_eq!(target.ai.tool_execution_timeout_secs, Some(456)); + assert!(!target.ai.enable_deferred_tool_loading); + assert_eq!(target.ai.browser_control_preferred_browser, "edge"); + let model = target + .ai + .models + .iter() + .find(|model| model.id == "legacy-model") + .unwrap(); + assert_eq!(model.api_key, "fixture-api-key"); + assert_eq!( + model.custom_headers.as_ref().unwrap()["Authorization"], + "Bearer fixture-header-secret" + ); + assert_eq!( + model.custom_request_body.as_deref(), + Some(r#"{"token":"fixture-body-secret"}"#) + ); + assert_eq!(target.app.voice_call.api_key, "fixture-voice-secret"); + assert_eq!( + target.mcp_servers, + Some(serde_json::json!({ + "fixture": { + "command": "fixture-mcp", + "env": {"TOKEN": "fixture-mcp-secret"} + } + })) + ); + assert_eq!(target.product_id, GlobalConfig::default().product_id); assert_eq!(sha256(&source_config_path(&roots)), source_before); assert!(report.requires_reauthentication.is_empty()); @@ -708,7 +1005,16 @@ mod tests { .flat_map(|entry| walk_files(&entry.unwrap().path())) .flat_map(|path| fs::read(path).unwrap()) .collect::>(); - assert!(!String::from_utf8_lossy(&stage).contains("fixture-secret")); + let stage = String::from_utf8_lossy(&stage); + for secret in [ + "fixture-api-key", + "fixture-header-secret", + "fixture-body-secret", + "fixture-voice-secret", + "fixture-mcp-secret", + ] { + assert!(!stage.contains(secret), "staging leaked {secret}"); + } } #[test] @@ -717,12 +1023,23 @@ mod tests { let roots = test_roots(temp.path()); seed_source(&roots, true); let mut target = GlobalConfig::default(); - target.app.language = "en-US".to_string(); - target.ai.models.push(AIModelConfig { - id: "legacy-model".to_string(), - api_key: "target-secret".to_string(), - ..AIModelConfig::default() - }); + target.app.language = "fr-FR".to_string(); + target.app.close_button_behavior = "quit".to_string(); + target.ai.default_models.primary = Some("target-model".to_string()); + target.ai.models.extend([ + AIModelConfig { + id: "target-model".to_string(), + name: "Target model".to_string(), + ..AIModelConfig::default() + }, + AIModelConfig { + id: "legacy-model".to_string(), + api_key: "target-secret".to_string(), + custom_headers: Some(Default::default()), + custom_request_body: Some(String::new()), + ..AIModelConfig::default() + }, + ]); atomic_write_json(&target_config_path(&roots), &target).unwrap(); let source = probe_legacy_source(&roots, ProbeLimits::default()) .unwrap() @@ -742,25 +1059,132 @@ mod tests { .execute(&plan, &CancellationToken::default(), &NoCrashInjection) .unwrap(); let target = read_target_config(&roots).unwrap(); - assert_eq!(target.app.language, "en-US"); - assert_eq!(target.ai.models[0].api_key, "target-secret"); + assert_eq!(target.app.language, "fr-FR"); + assert_eq!(target.app.close_button_behavior, "quit"); + assert_eq!( + target.ai.default_models.primary.as_deref(), + Some("target-model") + ); + assert_eq!( + target.ai.default_models.fast.as_deref(), + Some("legacy-model") + ); + let legacy_model = target + .ai + .models + .iter() + .find(|model| model.id == "legacy-model") + .unwrap(); + assert_eq!(legacy_model.api_key, "target-secret"); + assert_eq!( + legacy_model.custom_headers.as_ref().unwrap()["Authorization"], + "Bearer fixture-header-secret" + ); + assert_eq!( + legacy_model.custom_request_body.as_deref(), + Some(r#"{"token":"fixture-body-secret"}"#) + ); } fn seed_source(roots: &MigrationRoots, with_secret: bool) { let source = serde_json::json!({ - "app": {"language": "zh-CN", "auto_update": false, "telemetry": false}, + "product_id": "bitfun", + "app": { + "language": "en-US", + "auto_update": false, + "telemetry": true, + "confirm_on_exit": false, + "restore_windows": false, + "prevent_sleep": true, + "zoom_level": 1.25, + "notifications": {"duration": 8123}, + "voice_call": { + "api_key": if with_secret { "fixture-voice-secret" } else { "" } + } + }, + "editor": { + "font_size": 17, + "word_wrap": "on", + "format_on_save": false + }, + "terminal": { + "default_shell": "pwsh", + "terminal_panel_position": "bottom", + "font_size": 16, + "scrollback": 4321 + }, + "workspace": { + "max_file_size": 123456, + "line_ending": "lf", + "insert_final_newline": false + }, + "tool_permissions": { + "interaction": {"auto_approve_ask": true} + }, "appearance": {"theme_id": "bitfun-dark"}, "ai": { - "agent_profiles": {"coding_shared": {"enabled_skills": ["user::bitfun::user-skill"]}}, + "default_models": { + "primary": "legacy-model", + "fast": "legacy-model" + }, + "agent_model_defaults": { + "mode": "legacy-model", + "subagents": { + "default": {"kind": "fixed", "model_id": "legacy-model"}, + "builtin": {}, + "fork": {"kind": "inherit"} + } + }, + "agent_profiles": { + "coding_shared": { + "added_tools": ["LegacyTool"], + "removed_tools": ["ReadFile"], + "disabled_user_skills": ["user::bitfun::disabled-skill"], + "enabled_skills": ["user::bitfun::user-skill"] + } + }, + "skill_settings": { + "globally_disabled_user_skills": ["user::bitfun::global-disabled-skill"] + }, + "review_teams": { + "default": {"reviewer_timeout_seconds": 77} + }, + "subagent_max_concurrency": 3, + "stream_idle_timeout_secs": 321, + "stream_ttft_timeout_secs": 123, + "tool_execution_timeout_secs": 456, + "enable_deferred_tool_loading": false, + "browser_control_preferred_browser": "edge", "models": [{ "id": "legacy-model", "name": "Legacy model", "provider": "openai", "model_name": "legacy-model", "base_url": "https://example.invalid/v1", - "api_key": if with_secret { "fixture-secret" } else { "" } + "enabled": true, + "category": "general_chat", + "capabilities": ["text_chat"], + "api_key": if with_secret { "fixture-api-key" } else { "" }, + "custom_headers": if with_secret { + serde_json::json!({"Authorization": "Bearer fixture-header-secret"}) + } else { + serde_json::json!({}) + }, + "custom_headers_mode": "merge", + "custom_request_body": if with_secret { + r#"{"token":"fixture-body-secret"}"# + } else { + "" + }, + "custom_request_body_mode": "merge" }] }, + "mcp_servers": { + "fixture": { + "command": "fixture-mcp", + "env": {"TOKEN": if with_secret { "fixture-mcp-secret" } else { "" }} + } + }, "schema_version": 1, "version": "0.2.19", "last_modified": 0 diff --git a/src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs b/src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs index 7277761714..d97b2773d5 100644 --- a/src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs +++ b/src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs @@ -2,6 +2,7 @@ use super::common::{ backup_domain_dir, backup_file_once, io_error, read_bounded_json, read_optional_bounded_json, relative_display, restore_unverified_file, stage_domain_dir, validate_regular_file, }; +use crate::infrastructure::app_paths::PathManager; use crate::service::session_projection_format::validate_runtime_event_log; use crate::service::workspace::persistence::{ current_workspace_storage_id, validate_workspace_persistence_data, WorkspacePersistenceData, @@ -19,8 +20,8 @@ use openbitfun_product_domains::legacy_migration::{ MigrationDomainResult, MigrationDomainState, ScanFinding, }; use openbitfun_services_core::session::{ - OfflineSessionBundle, OfflineSessionImportStore, SessionRelationship, StoredDialogTurnFile, - StoredSessionMetadataFile, SESSION_STORAGE_SCHEMA_VERSION, + OfflineSessionBundle, OfflineSessionImportStore, SessionMetadata, SessionRelationship, + StoredDialogTurnFile, StoredSessionMetadataFile, SESSION_STORAGE_SCHEMA_VERSION, }; use openbitfun_services_core::workspace_identity::{ canonicalize_local_workspace_root, normalize_remote_workspace_path, LOCAL_WORKSPACE_SSH_HOST, @@ -40,12 +41,12 @@ const MAX_RUNTIME_DEPTH: usize = 16; const SESSION_ROOT_FILES: &[&str] = &[ "state.json", - "prompt_cache.json", "turn-catalog.json", "token-anchors.json", "session-revert.json", "evidence-ledger.json", ]; +const SESSION_REBUILDABLE_ROOT_FILES: &[&str] = &["prompt_cache.json"]; const SESSION_OWNED_DIRECTORIES: &[&str] = &["snapshots", "artifacts", "tool-results"]; pub(crate) struct WorkspaceSessionsAdapter; @@ -78,10 +79,20 @@ pub(crate) struct RuntimeEventManifestEntry { pub(crate) turn_ids: BTreeSet, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AssistantWorkspaceManifestEntry { + pub(crate) relative_path: String, + pub(crate) action: SessionImportAction, + pub(crate) expected_hash: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct WorkspaceSessionsManifest { pub(crate) workspace_id_map: BTreeMap, + #[serde(default)] + pub(crate) assistant_workspaces: Vec, pub(crate) sessions: Vec, pub(crate) runtime_events: Vec, pub(crate) target_workspace_existed: bool, @@ -107,6 +118,7 @@ struct LegacyWorkspacePersistenceData { struct WorkspaceSessionsPlan { workspace_data: WorkspacePersistenceData, workspace_id_map: BTreeMap, + assistant_workspaces: Vec, sessions: Vec, runtime_events: Vec, conflicts: Vec, @@ -117,10 +129,19 @@ struct WorkspaceSessionsPlan { logical_bytes: u64, } +struct PlannedAssistantWorkspace { + relative_path: PathBuf, + source_path: PathBuf, + action: SessionImportAction, + expected_hash: String, + logical_bytes: u64, +} + struct PlannedSession { runtime_relative: PathBuf, bundle: OfflineSessionBundle, auxiliary_files: Vec<(PathBuf, PathBuf)>, + state_bytes_override: Option>, action: SessionImportAction, expected_hash: String, } @@ -150,14 +171,16 @@ impl LegacyDomainAdapter for WorkspaceSessionsAdapter { FindingSeverity::Warning }, entity_count: (plan.workspace_id_map.len() + + plan.assistant_workspaces.len() + plan.sessions.len() + plan.runtime_events.len()) as u64, logical_bytes: plan.logical_bytes, source_schema: Some("bitfun.workspace-session.v1".to_string()), migratable: true, detail: format!( - "{} workspaces, {} Sessions, and {} runtime event logs are owner-readable", + "{} workspaces, {} personal assistant directories, {} Sessions, and {} runtime event logs are owner-readable", plan.workspace_id_map.len(), + plan.assistant_workspaces.len(), plan.sessions.len(), plan.runtime_events.len() ), @@ -176,6 +199,17 @@ impl LegacyDomainAdapter for WorkspaceSessionsAdapter { &plan.workspace_data, )?; + for workspace in &plan.assistant_workspaces { + if workspace.action != SessionImportAction::Import { + continue; + } + let staged = domain_root + .join("personal-assistant") + .join(&workspace.relative_path); + copy_tree(&workspace.source_path, &staged)?; + require_tree_hash(&staged, &workspace.expected_hash)?; + } + let runtime = offline_runtime()?; for session in &plan.sessions { if session.action != SessionImportAction::Import { @@ -188,8 +222,14 @@ impl LegacyDomainAdapter for WorkspaceSessionsAdapter { .map_err(|error| owner_error("write staged Session", error))?; let staged_session = sessions_root.join(&session.bundle.metadata.session_id); for (relative, source) in &session.auxiliary_files { - let bytes = fs::read(source).map_err(|error| io_error(source, error))?; - atomic_write_bytes(&staged_session.join(relative), &bytes)?; + if relative == Path::new("state.json") { + if let Some(bytes) = &session.state_bytes_override { + atomic_write_bytes(&staged_session.join(relative), bytes)?; + continue; + } + } + let source_bytes = fs::read(source).map_err(|error| io_error(source, error))?; + atomic_write_bytes(&staged_session.join(relative), &source_bytes)?; } require_tree_hash(&staged_session, &session.expected_hash)?; } @@ -210,6 +250,11 @@ impl LegacyDomainAdapter for WorkspaceSessionsAdapter { let manifest = WorkspaceSessionsManifest { workspace_id_map: plan.workspace_id_map, + assistant_workspaces: plan + .assistant_workspaces + .iter() + .map(assistant_workspace_manifest_entry) + .collect(), sessions: plan.sessions.iter().map(session_manifest_entry).collect(), runtime_events: plan .runtime_events @@ -237,7 +282,12 @@ impl LegacyDomainAdapter for WorkspaceSessionsAdapter { .iter() .filter(|entry| entry.action == SessionImportAction::Import) .count() - + manifest.workspace_id_map.len(); + + manifest.workspace_id_map.len() + + manifest + .assistant_workspaces + .iter() + .filter(|entry| entry.action == SessionImportAction::Import) + .count(); let skipped = manifest .sessions .iter() @@ -247,8 +297,13 @@ impl LegacyDomainAdapter for WorkspaceSessionsAdapter { .runtime_events .iter() .filter(|entry| entry.action != SessionImportAction::Import) + .count() + + manifest + .assistant_workspaces + .iter() + .filter(|entry| entry.action != SessionImportAction::Import) .count(); - let warnings = manifest + let mut warnings = manifest .skipped_paths .iter() .map(|path| MigrationDiagnostic { @@ -260,7 +315,23 @@ impl LegacyDomainAdapter for WorkspaceSessionsAdapter { .to_string(), action: Some("Review the migration report before removing legacy data".to_string()), }) - .collect(); + .collect::>(); + let orphaned_relationships = orphaned_session_relationship_count(&manifest); + if orphaned_relationships > 0 { + warnings.push(MigrationDiagnostic { + code: "session_parent_not_present".to_string(), + severity: FindingSeverity::Warning, + domain: Some(self.domain()), + relative_path: None, + message: format!( + "{orphaned_relationships} imported Sessions reference a parent Session that is not present in the legacy source; the relationship metadata was preserved" + ), + action: Some( + "No action is required; the affected Sessions remain available as standalone history" + .to_string(), + ), + }); + } Ok(MigrationDomainResult { domain: self.domain(), state: MigrationDomainState::Staged, @@ -286,6 +357,13 @@ impl LegacyDomainAdapter for WorkspaceSessionsAdapter { ) .map_err(|error| owner_error("validate staged Workspace registry", error))?; + for entry in imported_assistant_workspaces(&manifest) { + let path = domain_root + .join("personal-assistant") + .join(path_from_manifest(&entry.relative_path)?); + require_tree_hash(&path, &entry.expected_hash)?; + } + let runtime = offline_runtime()?; for entry in imported_sessions(&manifest) { let sessions_root = domain_root @@ -314,7 +392,7 @@ impl LegacyDomainAdapter for WorkspaceSessionsAdapter { .map_err(|error| owner_error("validate staged runtime event log", error))?; require_file_hash(&path, &entry.expected_hash)?; } - validate_session_relationship_closure(&manifest) + Ok(()) } fn commit(&self, context: &DomainContext<'_>) -> LegacyMigrationResult<()> { @@ -344,6 +422,20 @@ impl LegacyDomainAdapter for WorkspaceSessionsAdapter { atomic_write_bytes(&target_workspace, &workspace_bytes)?; } + for entry in imported_assistant_workspaces(&manifest) { + let relative = path_from_manifest(&entry.relative_path)?; + install_directory_idempotent( + &domain_root.join("personal-assistant").join(&relative), + &context + .roots + .target_home_root + .join("personal_assistant") + .join(relative), + &entry.expected_hash, + &context.plan.run_id, + )?; + } + for entry in imported_sessions(&manifest) { let relative = path_from_manifest(&entry.runtime_relative)?; let staged = domain_root @@ -399,6 +491,15 @@ impl LegacyDomainAdapter for WorkspaceSessionsAdapter { )); } + for entry in imported_assistant_workspaces(&manifest) { + let path = context + .roots + .target_home_root + .join("personal_assistant") + .join(path_from_manifest(&entry.relative_path)?); + require_tree_hash(&path, &entry.expected_hash)?; + } + let runtime = offline_runtime()?; for entry in imported_sessions(&manifest) { let relative = path_from_manifest(&entry.runtime_relative)?; @@ -445,6 +546,17 @@ impl LegacyDomainAdapter for WorkspaceSessionsAdapter { manifest.target_workspace_existed, )?; let domain_root = stage_domain_dir(context, "workspace-sessions"); + for entry in imported_assistant_workspaces(&manifest) { + let relative = path_from_manifest(&entry.relative_path)?; + remove_directory_if_matches( + &domain_root.join("personal-assistant").join(&relative), + &context + .roots + .target_home_root + .join("personal_assistant") + .join(relative), + )?; + } for entry in imported_sessions(&manifest) { let relative = path_from_manifest(&entry.runtime_relative)?; remove_directory_if_matches( @@ -489,6 +601,19 @@ fn plan_workspace_sessions(roots: &MigrationRoots) -> LegacyMigrationResult>(); @@ -496,18 +621,27 @@ fn plan_workspace_sessions(roots: &MigrationRoots) -> LegacyMigrationResult LegacyMigrationResult LegacyMigrationResult(); Ok(WorkspaceSessionsPlan { workspace_data, workspace_id_map, + assistant_workspaces, sessions, runtime_events, conflicts, @@ -621,6 +765,7 @@ fn plan_workspace_sessions(roots: &MigrationRoots) -> LegacyMigrationResult, +) -> LegacyMigrationResult> { + let source_root = roots.legacy_home_root.join("personal_assistant"); + if !source_root.exists() { + return Ok(Vec::new()); + } + let target_root = roots.target_home_root.join("personal_assistant"); + let mut planned = Vec::new(); + for source_path in child_directories(&source_root)? { + let relative_path = PathBuf::from(file_name(&source_path)?); + let (expected_hash, logical_bytes) = hash_tree_with_size(&source_path)?; + let target_path = target_root.join(&relative_path); + let action = if !target_path.exists() { + SessionImportAction::Import + } else if hash_tree(&target_path)? == expected_hash { + SessionImportAction::Duplicate + } else { + conflicts.push(MigrationConflict { + domain: MigrationDomainId::WorkspaceSessions, + code: "assistant_workspace_target_wins".to_string(), + source_summary: format!( + "legacy personal assistant workspace {}", + relative_path.display() + ), + target_summary: format!( + "current personal assistant workspace {}", + relative_path.display() + ), + resolution: ConflictResolution::TargetWins, + }); + SessionImportAction::TargetWins + }; + planned.push(PlannedAssistantWorkspace { + relative_path, + source_path, + action, + expected_hash, + logical_bytes, + }); + } + Ok(planned) +} + +fn relocate_assistant_session_metadata( + metadata: &mut SessionMetadata, + relocations: &BTreeMap, +) -> Option { + let project_workspace = + relocate_session_path(&mut metadata.project_workspace_path, relocations); + let workspace = relocate_session_path(&mut metadata.workspace_path, relocations); + let execution = metadata.execution_target.as_mut().and_then(|target| { + let relocated = relocations.get(&native_path_key(Path::new(&target.root_path)))?; + target.root_path = relocated.to_string_lossy().into_owned(); + Some(relocated.clone()) + }); + project_workspace.or(workspace).or(execution) +} + +fn relocate_session_path( + value: &mut Option, + relocations: &BTreeMap, +) -> Option { + let relocated = relocations.get(&native_path_key(Path::new(value.as_deref()?)))?; + *value = Some(relocated.to_string_lossy().into_owned()); + Some(relocated.clone()) +} + +fn relocate_assistant_session_state( + legacy_home_root: &Path, + state_path: &Path, + relocations: &BTreeMap, +) -> LegacyMigrationResult>> { + if !state_path.is_file() { + return Ok(None); + } + let mut state: serde_json::Value = read_bounded_json(legacy_home_root, state_path)?; + let Some(config) = state + .get_mut("config") + .and_then(serde_json::Value::as_object_mut) + else { + return Ok(None); + }; + + let mut changed = false; + for key in ["workspace_path", "project_workspace_path"] { + if let Some(value) = config.get_mut(key) { + changed |= relocate_json_path(value, relocations); + } + } + if let Some(execution_target) = config + .get_mut("execution_target") + .and_then(serde_json::Value::as_object_mut) + { + for key in ["rootPath", "root_path"] { + if let Some(value) = execution_target.get_mut(key) { + changed |= relocate_json_path(value, relocations); + } + } + } + + changed + .then(|| serde_json::to_vec(&state).map_err(json_error)) + .transpose() +} + +fn relocate_json_path( + value: &mut serde_json::Value, + relocations: &BTreeMap, +) -> bool { + let Some(source_path) = value.as_str() else { + return false; + }; + let Some(target_path) = relocations.get(&native_path_key(Path::new(source_path))) else { + return false; + }; + *value = serde_json::Value::String(target_path.to_string_lossy().into_owned()); + true +} + +fn assistant_session_runtime_relative(workspace_path: PathBuf) -> PathBuf { + let canonical = dunce::canonicalize(&workspace_path).unwrap_or(workspace_path); + let slug = PathManager::build_project_runtime_slug(&canonical.to_string_lossy()); + PathBuf::from("projects").join(slug).join("sessions") +} + +fn native_path_key(path: &Path) -> String { + let key = path + .to_string_lossy() + .replace('\\', "/") + .trim_end_matches('/') + .to_string(); + #[cfg(windows)] + { + key.to_ascii_lowercase() + } + #[cfg(not(windows))] + { + key + } +} + fn plan_sessions( roots: &MigrationRoots, target_sessions: &HashMap>, + assistant_path_relocations: &BTreeMap, conflicts: &mut Vec, ) -> LegacyMigrationResult<(Vec, Vec)> { let session_roots = find_session_roots(&roots.legacy_home_root)?; @@ -662,7 +951,7 @@ fn plan_sessions( let mut skipped_paths = Vec::new(); let mut source_ids = HashMap::::new(); for sessions_root in session_roots { - let runtime_relative = sessions_root + let source_runtime_relative = sessions_root .strip_prefix(&roots.legacy_home_root) .map_err(|_| LegacyMigrationError::PathEscape(sessions_root.clone()))? .to_path_buf(); @@ -696,8 +985,17 @@ fn plan_sessions( ))); } let turns = read_session_turns(&roots.legacy_home_root, &session_dir, &session_id)?; + let mut session_metadata = metadata_file.metadata; + let relocated_assistant_path = relocate_assistant_session_metadata( + &mut session_metadata, + assistant_path_relocations, + ); + let runtime_relative = relocated_assistant_path + .clone() + .map(assistant_session_runtime_relative) + .unwrap_or_else(|| source_runtime_relative.clone()); let bundle = OfflineSessionBundle { - metadata: metadata_file.metadata, + metadata: session_metadata, turns, }; bundle @@ -708,7 +1006,17 @@ fn plan_sessions( &session_dir, &mut skipped_paths, )?; - let expected_hash = expected_session_hash(&bundle, &auxiliary_files)?; + let state_bytes_override = if relocated_assistant_path.is_some() { + relocate_assistant_session_state( + &roots.legacy_home_root, + &session_dir.join("state.json"), + assistant_path_relocations, + )? + } else { + None + }; + let expected_hash = + expected_session_hash(&bundle, &auxiliary_files, state_bytes_override.as_deref())?; if auxiliary_files .len() .saturating_add(bundle.turns.len()) @@ -720,7 +1028,9 @@ fn plan_sessions( session_dir.display() ))); } - if expected_bundle_bytes(&bundle, &auxiliary_files)? > MAX_SESSION_BYTES { + if expected_bundle_bytes(&bundle, &auxiliary_files, state_bytes_override.as_deref())? + > MAX_SESSION_BYTES + { return Err(LegacyMigrationError::ResourceLimit(format!( "Session exceeds {MAX_SESSION_BYTES} bytes: {}", session_dir.display() @@ -766,6 +1076,7 @@ fn plan_sessions( runtime_relative: runtime_relative.clone(), bundle, auxiliary_files, + state_bytes_override, action, expected_hash, }); @@ -919,6 +1230,9 @@ fn collect_auxiliary_session_files( if name == "metadata.json" { continue; } + if SESSION_REBUILDABLE_ROOT_FILES.contains(&name.as_str()) { + continue; + } if SESSION_ROOT_FILES.contains(&name.as_str()) { validate_owned_file(legacy_home_root, session_dir, &path, &mut files)?; } else { @@ -1144,6 +1458,7 @@ fn merge_reference_list( fn expected_session_hash( bundle: &OfflineSessionBundle, auxiliary_files: &[(PathBuf, PathBuf)], + state_bytes_override: Option<&[u8]>, ) -> LegacyMigrationResult { let mut entries = Vec::new(); entries.push(( @@ -1158,6 +1473,12 @@ fn expected_session_hash( )); } for (relative, source) in auxiliary_files { + if relative == Path::new("state.json") { + if let Some(bytes) = state_bytes_override { + entries.push((relative.clone(), bytes.to_vec())); + continue; + } + } entries.push(( relative.clone(), fs::read(source).map_err(|error| io_error(source, error))?, @@ -1167,12 +1488,17 @@ fn expected_session_hash( } fn expected_session_bytes(session: &PlannedSession) -> LegacyMigrationResult { - expected_bundle_bytes(&session.bundle, &session.auxiliary_files) + expected_bundle_bytes( + &session.bundle, + &session.auxiliary_files, + session.state_bytes_override.as_deref(), + ) } fn expected_bundle_bytes( bundle: &OfflineSessionBundle, auxiliary_files: &[(PathBuf, PathBuf)], + state_bytes_override: Option<&[u8]>, ) -> LegacyMigrationResult { let metadata_bytes = serde_json::to_vec(&StoredSessionMetadataFile::new(bundle.metadata.clone())) @@ -1185,7 +1511,12 @@ fn expected_bundle_bytes( })?; let auxiliary_bytes = auxiliary_files.iter().try_fold( 0u64, - |total, (_, path)| -> LegacyMigrationResult { + |total, (relative, path)| -> LegacyMigrationResult { + if relative == Path::new("state.json") { + if let Some(bytes) = state_bytes_override { + return Ok(total.saturating_add(bytes.len() as u64)); + } + } let bytes = fs::metadata(path) .map_err(|error| io_error(path, error))? .len(); @@ -1220,10 +1551,18 @@ fn hash_entries(mut entries: Vec<(PathBuf, Vec)>) -> String { format!("sha256:{}", hex::encode(hasher.finalize())) } -fn hash_tree(root: &Path) -> LegacyMigrationResult { +fn hash_tree_with_size(root: &Path) -> LegacyMigrationResult<(String, u64)> { let mut entries = Vec::new(); collect_tree_entries(root, root, 0, &mut entries)?; - Ok(hash_entries(entries)) + let logical_bytes = entries + .iter() + .map(|(_, bytes)| bytes.len() as u64) + .sum::(); + Ok((hash_entries(entries), logical_bytes)) +} + +fn hash_tree(root: &Path) -> LegacyMigrationResult { + hash_tree_with_size(root).map(|(hash, _)| hash) } fn collect_tree_entries( @@ -1399,46 +1738,20 @@ fn verify_planned_file_state( Ok(()) } -fn validate_session_relationship_closure( - manifest: &WorkspaceSessionsManifest, -) -> LegacyMigrationResult<()> { +fn orphaned_session_relationship_count(manifest: &WorkspaceSessionsManifest) -> usize { let sessions = manifest .sessions .iter() - .map(|entry| (entry.session_id.as_str(), entry)) - .collect::>(); - let event_turns = manifest - .runtime_events + .map(|entry| entry.session_id.as_str()) + .collect::>(); + manifest + .sessions .iter() - .map(|entry| (entry.session_id.as_str(), &entry.turn_ids)) - .collect::>(); - for entry in &manifest.sessions { - let Some(relationship) = &entry.relationship else { - continue; - }; - let Some(parent_session_id) = relationship.parent_session_id.as_deref() else { - continue; - }; - let parent = sessions.get(parent_session_id).ok_or_else(|| { - LegacyMigrationError::UnsupportedSource(format!( - "Session {} references a missing parent Session", - entry.session_id - )) - })?; - if let Some(parent_turn_id) = relationship.parent_dialog_turn_id.as_deref() { - let in_persisted_turns = parent.turn_ids.contains(parent_turn_id); - let in_runtime_events = event_turns - .get(parent_session_id) - .is_some_and(|turns| turns.contains(parent_turn_id)); - if !in_persisted_turns && !in_runtime_events { - return Err(LegacyMigrationError::UnsupportedSource(format!( - "Session {} references a missing parent Turn", - entry.session_id - ))); - } - } - } - Ok(()) + .filter(|entry| entry.action == SessionImportAction::Import) + .filter_map(|entry| entry.relationship.as_ref()) + .filter_map(|relationship| relationship.parent_session_id.as_deref()) + .filter(|parent_session_id| !sessions.contains(parent_session_id)) + .count() } fn session_manifest_entry(session: &PlannedSession) -> SessionManifestEntry { @@ -1460,6 +1773,25 @@ fn session_manifest_entry(session: &PlannedSession) -> SessionManifestEntry { } } +fn assistant_workspace_manifest_entry( + workspace: &PlannedAssistantWorkspace, +) -> AssistantWorkspaceManifestEntry { + AssistantWorkspaceManifestEntry { + relative_path: workspace.relative_path.to_string_lossy().replace('\\', "/"), + action: workspace.action, + expected_hash: workspace.expected_hash.clone(), + } +} + +fn imported_assistant_workspaces( + manifest: &WorkspaceSessionsManifest, +) -> impl Iterator { + manifest + .assistant_workspaces + .iter() + .filter(|entry| entry.action == SessionImportAction::Import) +} + fn imported_sessions( manifest: &WorkspaceSessionsManifest, ) -> impl Iterator { @@ -1562,6 +1894,7 @@ fn json_error(error: serde_json::Error) -> LegacyMigrationError { #[cfg(test)] mod tests { use super::*; + use openbitfun_core_types::SessionExecutionTarget; #[test] fn remote_workspace_paths_are_normalized_with_posix_semantics() { @@ -1592,4 +1925,209 @@ mod tests { .unwrap() .starts_with("remote_")); } + + #[test] + fn personal_assistant_tree_and_session_paths_are_rehomed_together() { + let temp = test_tempdir("personal-assistant"); + let roots = fixture_roots(temp.path()); + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../services/legacy-migration/tests/fixtures/v0.2.19"); + copy_tree(&fixture.join("user-root"), &roots.legacy_user_root).unwrap(); + copy_tree(&fixture.join("home"), &roots.legacy_home_root).unwrap(); + + let source_assistant = roots + .legacy_home_root + .join("personal_assistant/workspace-assistant"); + atomic_write_bytes(&source_assistant.join("IDENTITY.md"), b"Assistant identity").unwrap(); + atomic_write_bytes( + &source_assistant.join("user-content/page.html"), + b"

preserved

", + ) + .unwrap(); + let target_assistant = roots + .target_home_root + .join("personal_assistant/workspace-assistant"); + + let workspace_path = source_workspace_data_path(&roots); + let mut workspace_data: serde_json::Value = + serde_json::from_slice(&fs::read(&workspace_path).unwrap()).unwrap(); + workspace_data["workspaces"]["assistant-legacy"] = serde_json::json!({ + "id": "assistant-legacy", + "name": "Personal assistant", + "rootPath": source_assistant, + "workspaceType": "Other", + "workspaceKind": "assistant", + "status": "Inactive", + "languages": [], + "openedAt": "2026-01-01T00:00:00Z", + "lastAccessed": "2026-01-01T00:00:00Z", + "description": null, + "tags": [], + "statistics": null, + "relatedPaths": [], + "metadata": { "sshHost": "localhost" } + }); + workspace_data["recent_assistant_workspaces"] = serde_json::json!(["assistant-legacy"]); + atomic_write_json(&workspace_path, &workspace_data).unwrap(); + + let metadata_path = roots + .legacy_home_root + .join("projects/c--fixture-workspace/sessions/session-1/metadata.json"); + let mut metadata: StoredSessionMetadataFile = + serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap(); + let source_display = source_assistant.to_string_lossy().into_owned(); + metadata.metadata.workspace_path = Some(source_display.clone()); + metadata.metadata.project_workspace_path = Some(source_display.clone()); + metadata.metadata.execution_target = + Some(SessionExecutionTarget::local(source_display.clone())); + atomic_write_json(&metadata_path, &metadata).unwrap(); + let session_dir = metadata_path.parent().unwrap(); + atomic_write_json( + &session_dir.join("state.json"), + &serde_json::json!({ + "schema_version": 1, + "config": { + "workspace_path": source_display.clone(), + "project_workspace_path": source_display.clone(), + "execution_target": { + "kind": "local", + "rootPath": source_display.clone() + }, + "unknown_future_field": "preserved" + }, + "historical_tool_path": source_display.clone() + }), + ) + .unwrap(); + atomic_write_json( + &session_dir.join("prompt_cache.json"), + &serde_json::json!({ + "schema_version": 1, + "user_context": { + "content": format!("Current Working Directory: {source_display}") + } + }), + ) + .unwrap(); + + let plan = plan_workspace_sessions(&roots).unwrap(); + assert_eq!(plan.assistant_workspaces.len(), 1); + assert_eq!(plan.assistant_workspaces[0].source_path, source_assistant); + let assistant = plan + .workspace_data + .workspaces + .values() + .find(|workspace| workspace.workspace_kind == WorkspaceKind::Assistant) + .unwrap(); + assert_eq!(assistant.root_path, target_assistant); + assert!(!plan.requires_relocation.contains(&assistant.id)); + + let session = plan + .sessions + .iter() + .find(|session| session.bundle.metadata.session_id == "session-1") + .unwrap(); + let target_key = native_path_key(&target_assistant); + assert_eq!( + session + .bundle + .metadata + .workspace_path + .as_deref() + .map(Path::new) + .map(native_path_key), + Some(target_key.clone()) + ); + assert_eq!( + session + .bundle + .metadata + .project_workspace_path + .as_deref() + .map(Path::new) + .map(native_path_key), + Some(target_key.clone()) + ); + assert_eq!( + session + .bundle + .metadata + .execution_target + .as_ref() + .map(|target| native_path_key(Path::new(&target.root_path))), + Some(target_key) + ); + assert_eq!( + session.runtime_relative, + assistant_session_runtime_relative(target_assistant.clone()) + ); + assert!(!session + .auxiliary_files + .iter() + .any(|(relative, _)| relative == Path::new("prompt_cache.json"))); + assert!(!plan + .skipped_paths + .iter() + .any(|path| path.ends_with("prompt_cache.json"))); + + let migrated_state: serde_json::Value = serde_json::from_slice( + session + .state_bytes_override + .as_deref() + .expect("assistant Session state should be rewritten"), + ) + .unwrap(); + for pointer in [ + "/config/workspace_path", + "/config/project_workspace_path", + "/config/execution_target/rootPath", + ] { + assert_eq!( + migrated_state + .pointer(pointer) + .and_then(serde_json::Value::as_str) + .map(Path::new) + .map(native_path_key), + Some(native_path_key(&target_assistant)), + "{pointer} should use the migrated assistant workspace" + ); + } + assert_eq!( + migrated_state + .pointer("/config/unknown_future_field") + .and_then(serde_json::Value::as_str), + Some("preserved") + ); + assert_eq!( + migrated_state + .pointer("/historical_tool_path") + .and_then(serde_json::Value::as_str), + Some(source_display.as_str()) + ); + } + + fn fixture_roots(root: &Path) -> MigrationRoots { + let legacy_user_root = root.join("legacy-user"); + MigrationRoots { + legacy_skills_root: legacy_user_root.join("skills"), + legacy_user_root, + legacy_home_root: root.join("legacy-home"), + legacy_ssh_root: root.join("legacy-ssh"), + target_user_root: root.join("target-user"), + target_home_root: root.join("target-home"), + target_skills_root: root.join("target-skills"), + target_ssh_root: root.join("target-ssh"), + } + } + + fn test_tempdir(label: &str) -> tempfile::TempDir { + let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("E:/tmp")); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix(&format!("openbitfun-migration-{label}-")) + .tempdir_in(root) + .unwrap() + } } diff --git a/src/crates/assembly/core/src/service/workspace/types.rs b/src/crates/assembly/core/src/service/workspace/types.rs index 39de53492a..07739a356a 100644 --- a/src/crates/assembly/core/src/service/workspace/types.rs +++ b/src/crates/assembly/core/src/service/workspace/types.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; -pub use openbitfun_runtime_ports::RelatedPath; +pub(crate) use openbitfun_runtime_ports::RelatedPath; /// Workspace type. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] diff --git a/src/crates/contracts/product-domains/src/legacy_migration/mod.rs b/src/crates/contracts/product-domains/src/legacy_migration/mod.rs index 054884de6b..635b255534 100644 --- a/src/crates/contracts/product-domains/src/legacy_migration/mod.rs +++ b/src/crates/contracts/product-domains/src/legacy_migration/mod.rs @@ -333,6 +333,58 @@ pub struct MigrationRunReport { pub requires_relocation: Vec, } +/// Content-free projection that may be used for migration release observation. +/// +/// Keep this shape limited to the result code, per-domain state, elapsed time, +/// and failure phase. In particular, it must not grow run identifiers, source +/// fingerprints, paths, counts, diagnostics, or user-authored content. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationReleaseObservation { + pub result_code: String, + pub domain_states: Vec, + pub duration_ms: u64, + pub failure_phase: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationDomainObservation { + pub domain: MigrationDomainId, + pub state: MigrationDomainState, +} + +/// A content-free diagnostic code included in an explicit failure export. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationFailureDiagnosticCode { + pub code: String, + pub severity: FindingSeverity, + pub domain: Option, +} + +/// Sanitized journal evidence included in an explicit failure export. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationFailureJournalEntry { + pub sequence: u64, + pub status: MigrationRunStatus, + pub phase: MigrationPhase, + pub domain: Option, + pub domain_state: Option, + pub code: String, +} + +/// Shareable failure evidence without source identity, paths, secrets, or text. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MigrationFailureDiagnostics { + pub format_version: u32, + pub observation: MigrationReleaseObservation, + pub diagnostic_codes: Vec, + pub journal: Vec, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MigrationPromptChoice { diff --git a/src/crates/services/legacy-migration/Cargo.toml b/src/crates/services/legacy-migration/Cargo.toml index de6518a87d..ad085cb0e6 100644 --- a/src/crates/services/legacy-migration/Cargo.toml +++ b/src/crates/services/legacy-migration/Cargo.toml @@ -39,7 +39,6 @@ windows = { workspace = true, features = [ "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", - "Win32_Security_WinTrust", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading", diff --git a/src/crates/services/legacy-migration/src/diagnostics.rs b/src/crates/services/legacy-migration/src/diagnostics.rs new file mode 100644 index 0000000000..578e491d4a --- /dev/null +++ b/src/crates/services/legacy-migration/src/diagnostics.rs @@ -0,0 +1,253 @@ +use crate::{atomic_write_json, LegacyMigrationError, LegacyMigrationResult, MigrationLayout}; +use openbitfun_product_domains::legacy_migration::{ + FindingSeverity, MigrationDiagnostic, MigrationDomainObservation, MigrationDomainState, + MigrationFailureDiagnosticCode, MigrationFailureDiagnostics, MigrationFailureJournalEntry, + MigrationJournalEvent, MigrationPhase, MigrationReleaseObservation, MigrationRunReport, + MigrationRunStatus, +}; +use std::fs; +use std::path::{Path, PathBuf}; + +const FAILURE_DIAGNOSTICS_FORMAT_VERSION: u32 = 1; +const MAX_DIAGNOSTIC_JOURNAL_BYTES: u64 = 4 * 1024 * 1024; +const MAX_DIAGNOSTIC_JOURNAL_ENTRIES: usize = 10_000; +const REDACTED_CODE: &str = "redacted_code"; + +pub(crate) fn persist_release_observation( + layout: &MigrationLayout, + report: &MigrationRunReport, + result_code: &str, + failure_phase: Option, + observed_at_ms: i64, +) -> LegacyMigrationResult<()> { + let observation = release_observation(report, result_code, failure_phase, observed_at_ms); + atomic_write_json(&layout.release_observation_path(), &observation) +} + +pub fn release_observation( + report: &MigrationRunReport, + result_code: &str, + failure_phase: Option, + observed_at_ms: i64, +) -> MigrationReleaseObservation { + MigrationReleaseObservation { + result_code: sanitize_code(result_code), + domain_states: report + .domain_results + .iter() + .map(|result| MigrationDomainObservation { + domain: result.domain, + state: result.state, + }) + .collect(), + duration_ms: elapsed_ms(report.started_at_ms, observed_at_ms), + failure_phase, + } +} + +/// Write a shareable failure-only diagnostic artifact next to the run metadata. +/// +/// The export deliberately projects trusted enums and sanitized result codes. +/// It never copies the request, plan, source fingerprint, diagnostic messages, +/// actions, paths, repair identifiers, entity counts, or user-authored content. +pub fn export_failure_diagnostics( + layout: &MigrationLayout, + report: &MigrationRunReport, +) -> LegacyMigrationResult { + if !matches!( + report.status, + MigrationRunStatus::FailedRecoverable | MigrationRunStatus::FailedManualActionRequired + ) { + return Err(LegacyMigrationError::InvalidRequest( + "failure diagnostics require a failed migration report".to_string(), + )); + } + + let mut journal = read_sanitized_journal(&layout.journal_path())?; + let failure = journal + .entries + .iter() + .rev() + .find(|entry| { + matches!( + entry.status, + MigrationRunStatus::FailedRecoverable + | MigrationRunStatus::FailedManualActionRequired + ) || entry.domain_state == Some(MigrationDomainState::Failed) + }) + .cloned(); + let result_code = failure + .as_ref() + .map(|entry| entry.code.as_str()) + .unwrap_or_else(|| default_result_code(report.status)); + let failure_phase = failure.as_ref().map(|entry| entry.phase); + let observed_at_ms = journal + .last_recorded_at_ms + .or(report.finished_at_ms) + .unwrap_or(report.started_at_ms); + + let mut diagnostic_codes = report + .diagnostics + .iter() + .chain( + report + .domain_results + .iter() + .flat_map(|result| result.warnings.iter()), + ) + .map(sanitize_diagnostic) + .collect::>(); + diagnostic_codes.append(&mut journal.supplemental_diagnostics); + deduplicate_diagnostics(&mut diagnostic_codes); + + let diagnostics = MigrationFailureDiagnostics { + format_version: FAILURE_DIAGNOSTICS_FORMAT_VERSION, + observation: release_observation(report, result_code, failure_phase, observed_at_ms), + diagnostic_codes, + journal: journal.entries, + }; + let path = layout.failure_diagnostics_path(); + atomic_write_json(&path, &diagnostics)?; + Ok(path) +} + +struct SanitizedJournal { + entries: Vec, + supplemental_diagnostics: Vec, + last_recorded_at_ms: Option, +} + +fn read_sanitized_journal(path: &Path) -> LegacyMigrationResult { + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(SanitizedJournal { + entries: Vec::new(), + supplemental_diagnostics: vec![diagnostic_code( + "journal_unavailable", + FindingSeverity::Warning, + )], + last_recorded_at_ms: None, + }); + } + Err(error) => return Err(LegacyMigrationError::io(path, error)), + }; + if metadata.len() > MAX_DIAGNOSTIC_JOURNAL_BYTES { + return Ok(SanitizedJournal { + entries: Vec::new(), + supplemental_diagnostics: vec![diagnostic_code( + "journal_size_limit_reached", + FindingSeverity::Warning, + )], + last_recorded_at_ms: None, + }); + } + + let bytes = fs::read(path).map_err(|error| LegacyMigrationError::io(path, error))?; + let mut entries = Vec::new(); + let mut supplemental_diagnostics = Vec::new(); + let mut last_recorded_at_ms = None; + for line in bytes + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + { + if entries.len() >= MAX_DIAGNOSTIC_JOURNAL_ENTRIES { + supplemental_diagnostics.push(diagnostic_code( + "journal_entry_limit_reached", + FindingSeverity::Warning, + )); + break; + } + match serde_json::from_slice::(line) { + Ok(event) => { + last_recorded_at_ms = Some( + last_recorded_at_ms.map_or(event.recorded_at_ms, |current: i64| { + current.max(event.recorded_at_ms) + }), + ); + entries.push(MigrationFailureJournalEntry { + sequence: event.sequence, + status: event.status, + phase: event.phase, + domain: event.domain, + domain_state: event.domain_state, + code: sanitize_code(&event.code), + }); + } + Err(_) => supplemental_diagnostics.push(diagnostic_code( + "journal_entry_invalid", + FindingSeverity::Warning, + )), + } + } + + Ok(SanitizedJournal { + entries, + supplemental_diagnostics, + last_recorded_at_ms, + }) +} + +fn sanitize_diagnostic(diagnostic: &MigrationDiagnostic) -> MigrationFailureDiagnosticCode { + MigrationFailureDiagnosticCode { + code: sanitize_code(&diagnostic.code), + severity: diagnostic.severity, + domain: diagnostic.domain, + } +} + +fn diagnostic_code(code: &str, severity: FindingSeverity) -> MigrationFailureDiagnosticCode { + MigrationFailureDiagnosticCode { + code: code.to_string(), + severity, + domain: None, + } +} + +fn deduplicate_diagnostics(diagnostics: &mut Vec) { + let mut unique = Vec::with_capacity(diagnostics.len()); + for diagnostic in diagnostics.drain(..) { + if !unique.contains(&diagnostic) { + unique.push(diagnostic); + } + } + *diagnostics = unique; +} + +fn sanitize_code(code: &str) -> String { + if !code.is_empty() + && code.len() <= 64 + && code + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + code.to_string() + } else { + REDACTED_CODE.to_string() + } +} + +fn default_result_code(status: MigrationRunStatus) -> &'static str { + match status { + MigrationRunStatus::Discovered => "discovered", + MigrationRunStatus::Scanned => "scanned", + MigrationRunStatus::Planned => "planned", + MigrationRunStatus::WaitingForProcesses => "waiting_for_processes", + MigrationRunStatus::Staging => "staging", + MigrationRunStatus::ValidatingStage => "validating_stage", + MigrationRunStatus::Committing => "committing", + MigrationRunStatus::ValidatingCommit => "validating_commit", + MigrationRunStatus::Completed => "completed", + MigrationRunStatus::CompletedWithWarnings => "completed_with_warnings", + MigrationRunStatus::Cancelled => "cancelled", + MigrationRunStatus::FailedRecoverable => "failed_recoverable", + MigrationRunStatus::FailedManualActionRequired => "failed_manual_action_required", + } +} + +fn elapsed_ms(started_at_ms: i64, observed_at_ms: i64) -> u64 { + if started_at_ms <= 0 || observed_at_ms <= started_at_ms { + return 0; + } + u64::try_from(observed_at_ms.saturating_sub(started_at_ms)).unwrap_or(u64::MAX) +} diff --git a/src/crates/services/legacy-migration/src/engine.rs b/src/crates/services/legacy-migration/src/engine.rs index d762366cd8..990bb455e8 100644 --- a/src/crates/services/legacy-migration/src/engine.rs +++ b/src/crates/services/legacy-migration/src/engine.rs @@ -1,6 +1,7 @@ use crate::{ - atomic_write_json, probe_legacy_source, LegacyMigrationError, LegacyMigrationResult, - MigrationLayout, MigrationLock, MigrationRoots, ProbeLimits, + atomic_write_json, diagnostics::persist_release_observation, probe_legacy_source, + LegacyMigrationError, LegacyMigrationResult, MigrationLayout, MigrationLock, MigrationRoots, + ProbeLimits, }; use openbitfun_product_domains::legacy_migration::{ FindingSeverity, LegacySourceDescriptor, MigrationConflict, MigrationDiagnostic, @@ -294,6 +295,17 @@ impl MigrationEngine { report.status, MigrationRunStatus::Completed | MigrationRunStatus::CompletedWithWarnings ) { + let result_code = match report.status { + MigrationRunStatus::CompletedWithWarnings => "migration_completed_with_warnings", + _ => "migration_completed", + }; + persist_release_observation( + &layout, + &report, + result_code, + None, + report.finished_at_ms.unwrap_or_else(now_ms), + )?; return Ok(report); } normalize_report(plan, &mut report); @@ -365,6 +377,7 @@ impl MigrationEngine { result_index, step.domain, MigrationPhase::Stage, + &error, )?; let _ = adapter.rollback_unverified(&context); return Err(domain_error(step.domain, error)); @@ -423,6 +436,7 @@ impl MigrationEngine { result_index, step.domain, MigrationPhase::ValidateStage, + &error, )?; let _ = adapter.rollback_unverified(&context); return Err(domain_error(step.domain, error)); @@ -472,6 +486,7 @@ impl MigrationEngine { result_index, step.domain, MigrationPhase::Commit, + &error, )?; let _ = adapter.rollback_unverified(&context); return Err(domain_error(step.domain, error)); @@ -518,6 +533,7 @@ impl MigrationEngine { result_index, step.domain, MigrationPhase::ValidateCommit, + &error, )?; let _ = adapter.rollback_unverified(&context); return Err(domain_error(step.domain, error)); @@ -533,12 +549,17 @@ impl MigrationEngine { result_index, step.domain, MigrationPhase::ValidateCommit, + &error, )?; let _ = adapter.rollback_unverified(&context); return Err(domain_error(step.domain, error)); } }; if finalized.domain != step.domain { + let error = LegacyMigrationError::InvalidPlan(format!( + "adapter {:?} finalized a result for {:?}", + step.domain, finalized.domain + )); record_domain_failure( &layout, &mut report, @@ -546,12 +567,10 @@ impl MigrationEngine { result_index, step.domain, MigrationPhase::ValidateCommit, + &error, )?; let _ = adapter.rollback_unverified(&context); - return Err(LegacyMigrationError::InvalidPlan(format!( - "adapter {:?} finalized a result for {:?}", - step.domain, finalized.domain - ))); + return Err(error); } finalized.state = MigrationDomainState::Verified; report.domain_results[result_index] = finalized; @@ -611,6 +630,11 @@ impl MigrationEngine { } else { MigrationRunStatus::Completed }; + let final_code = if has_warnings { + "migration_completed_with_warnings" + } else { + "migration_completed" + }; transition( &layout, &mut report, @@ -619,7 +643,7 @@ impl MigrationEngine { MigrationPhase::Finalize, None, None, - "migration_completed", + final_code, )?; emit_progress( &mut progress, @@ -628,7 +652,7 @@ impl MigrationEngine { MigrationPhase::Finalize, plan.steps.len(), true, - "migration_completed", + final_code, ); Ok(report) } @@ -759,6 +783,13 @@ fn load_or_create_report( ..MigrationRunReport::default() }; persist_report(layout, &report)?; + persist_release_observation( + layout, + &report, + "migration_planned", + None, + report.started_at_ms, + )?; Ok(report) } @@ -811,7 +842,7 @@ fn journal( code: &str, ) -> LegacyMigrationResult<()> { *sequence = sequence.saturating_add(1); - layout.append_journal(&MigrationJournalEvent { + let event = MigrationJournalEvent { format_version: CURRENT_MIGRATION_FORMAT_VERSION, sequence: *sequence, recorded_at_ms: now_ms(), @@ -821,7 +852,14 @@ fn journal( domain, domain_state, code: code.to_string(), - }) + }; + layout.append_journal(&event)?; + let failure_phase = matches!( + report.status, + MigrationRunStatus::FailedRecoverable | MigrationRunStatus::FailedManualActionRequired + ) + .then_some(phase); + persist_release_observation(layout, report, code, failure_phase, event.recorded_at_ms) } fn record_domain_failure( @@ -831,17 +869,13 @@ fn record_domain_failure( result_index: usize, domain: MigrationDomainId, phase: MigrationPhase, + error: &LegacyMigrationError, ) -> LegacyMigrationResult<()> { report.status = MigrationRunStatus::FailedRecoverable; report.domain_results[result_index].state = MigrationDomainState::Failed; - report.diagnostics.push(MigrationDiagnostic { - code: "domain_failed_recoverable".to_string(), - severity: FindingSeverity::Blocking, - domain: Some(domain), - message: "A migration domain failed and can be retried".to_string(), - action: Some("Retry from Data Migrator after resolving the reported condition".to_string()), - ..MigrationDiagnostic::default() - }); + report + .diagnostics + .push(domain_failure_diagnostic(domain, error)); persist_report(layout, report)?; journal( layout, @@ -854,6 +888,109 @@ fn record_domain_failure( ) } +fn domain_failure_diagnostic( + domain: MigrationDomainId, + error: &LegacyMigrationError, +) -> MigrationDiagnostic { + let (code, message, action) = match error { + LegacyMigrationError::UnsupportedSource(_) => ( + "domain_source_unsupported", + "A migration source record does not match a supported legacy format.", + "Keep the legacy data unchanged and export diagnostics for review.", + ), + LegacyMigrationError::PathEscape(_) | LegacyMigrationError::LinkedPath(_) => ( + "domain_path_unsafe", + "A migration path failed its safety checks.", + "Review linked or redirected paths before retrying the migration.", + ), + LegacyMigrationError::ResourceLimit(_) => ( + "domain_resource_limit", + "A migration domain exceeded a configured safety limit.", + "Export diagnostics and review the size or shape of the legacy data.", + ), + LegacyMigrationError::Io { source, .. } => io_failure_diagnostic(source), + LegacyMigrationError::Json { .. } => ( + "domain_json_invalid", + "A migration-owned JSON document is unreadable or has an unexpected shape.", + "Keep the legacy data unchanged and export diagnostics for review.", + ), + LegacyMigrationError::Sqlite { .. } => ( + "domain_sqlite_failed", + "A migration-owned SQLite database could not be copied or validated.", + "Close processes using the database and retry the migration.", + ), + _ => ( + "domain_validation_failed", + "A migration-owned record failed validation.", + "Keep the legacy data unchanged and export diagnostics for review.", + ), + }; + MigrationDiagnostic { + code: code.to_string(), + severity: FindingSeverity::Blocking, + domain: Some(domain), + message: message.to_string(), + action: Some(action.to_string()), + ..MigrationDiagnostic::default() + } +} + +fn io_failure_diagnostic(error: &std::io::Error) -> (&'static str, &'static str, &'static str) { + if is_path_too_long(error) { + return ( + "domain_path_too_long", + "A migration-owned path exceeds the platform path limit.", + "Enable long-path support or use a shorter data root before retrying.", + ); + } + if is_storage_full(error) { + return ( + "domain_storage_full", + "The destination volume does not have enough free space.", + "Free destination disk space and retry the migration.", + ); + } + match error.kind() { + std::io::ErrorKind::PermissionDenied => ( + "domain_io_permission_denied", + "A migration-owned file or directory denied access.", + "Close programs using the data, check permissions, and retry.", + ), + std::io::ErrorKind::NotFound => ( + "domain_io_not_found", + "A migration-owned source or staged file was not found.", + "Run a new scan and retry after confirming the legacy data is still present.", + ), + std::io::ErrorKind::AlreadyExists => ( + "domain_io_conflict", + "A migration target changed after the plan was created.", + "Run a new scan and plan before retrying the migration.", + ), + std::io::ErrorKind::InvalidData | std::io::ErrorKind::InvalidInput => ( + "domain_io_invalid_data", + "A migration-owned file could not be processed safely.", + "Keep the legacy data unchanged and export diagnostics for review.", + ), + _ => ( + "domain_io_failed", + "A migration-owned file or directory could not be read or written.", + "Export diagnostics, check the destination storage, and retry.", + ), + } +} + +fn is_path_too_long(error: &std::io::Error) -> bool { + cfg!(windows) && error.raw_os_error() == Some(206) +} + +fn is_storage_full(error: &std::io::Error) -> bool { + match error.raw_os_error() { + Some(112) if cfg!(windows) => true, + Some(28) if cfg!(unix) => true, + _ => false, + } +} + fn record_cancelled( layout: &MigrationLayout, report: &mut MigrationRunReport, diff --git a/src/crates/services/legacy-migration/src/handoff.rs b/src/crates/services/legacy-migration/src/handoff.rs index 4341696d08..a9a4230530 100644 --- a/src/crates/services/legacy-migration/src/handoff.rs +++ b/src/crates/services/legacy-migration/src/handoff.rs @@ -717,19 +717,6 @@ fn platform_process_entries() -> LegacyMigrationResult> { )) } -pub trait ExecutableTrustVerifier { - fn verify(&self, path: &Path) -> LegacyMigrationResult<()>; -} - -#[derive(Debug, Clone, Copy, Default)] -pub struct PlatformExecutableTrustVerifier; - -impl ExecutableTrustVerifier for PlatformExecutableTrustVerifier { - fn verify(&self, path: &Path) -> LegacyMigrationResult<()> { - platform_verify_executable(path) - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct TrustedExecutable { current_executable: PathBuf, @@ -754,7 +741,6 @@ impl TrustedInstallationResolver { current_executable: &Path, expected_current_binary_name: &str, expected_target_binary_name: &str, - verifier: &dyn ExecutableTrustVerifier, ) -> LegacyMigrationResult { validate_binary_name(expected_current_binary_name)?; validate_binary_name(expected_target_binary_name)?; @@ -784,8 +770,6 @@ impl TrustedInstallationResolver { if target.parent() != Some(install_root) { return Err(LegacyMigrationError::UntrustedExecutable(target)); } - verifier.verify(¤t)?; - verifier.verify(&target)?; Ok(TrustedExecutable { current_executable: current, target_executable: target, @@ -797,15 +781,61 @@ pub fn launch_trusted_executable( executable: &TrustedExecutable, arguments: &[&OsStr], ) -> LegacyMigrationResult { - let child = openbitfun_services_core::process_manager::create_detached_command( + let initial_spawn = openbitfun_services_core::process_manager::create_detached_command( executable.target_executable(), ) .args(arguments) - .spawn() - .map_err(|error| LegacyMigrationError::io(executable.target_executable(), error))?; + .spawn(); + + #[cfg(windows)] + let child = match initial_spawn { + Ok(child) => child, + Err(error) if allow_inherited_job_dev_retry(&error) => { + openbitfun_services_core::process_manager::create_inherited_job_process_group_command( + executable.target_executable(), + ) + .args(arguments) + .spawn() + .map_err(|retry_error| { + LegacyMigrationError::io(executable.target_executable(), retry_error) + })? + } + Err(error) => { + return Err(LegacyMigrationError::io( + executable.target_executable(), + error, + )); + } + }; + + #[cfg(not(windows))] + let child = initial_spawn + .map_err(|error| LegacyMigrationError::io(executable.target_executable(), error))?; + Ok(child.id()) } +#[cfg(windows)] +fn allow_inherited_job_dev_retry(error: &std::io::Error) -> bool { + should_retry_without_job_breakaway( + cfg!(debug_assertions), + std::env::var_os("OPENBITFUN_ALLOW_UNSIGNED_MIGRATOR_DEV").as_deref() + == Some(OsStr::new("1")), + error.kind(), + ) +} + +#[cfg(any(windows, test))] +fn should_retry_without_job_breakaway( + debug_assertions: bool, + unsigned_migrator_dev_enabled: bool, + error_kind: std::io::ErrorKind, +) -> bool { + debug_assertions + && unsigned_migrator_dev_enabled + && error_kind == std::io::ErrorKind::PermissionDenied +} + fn validate_binary_name(name: &str) -> LegacyMigrationResult<()> { if name.is_empty() || Path::new(name).components().count() != 1 @@ -854,73 +884,6 @@ fn reject_linked_executable(path: &Path) -> LegacyMigrationResult<()> { Ok(()) } -#[cfg(windows)] -fn platform_verify_executable(path: &Path) -> LegacyMigrationResult<()> { - use std::ffi::c_void; - use std::mem::size_of; - use std::os::windows::ffi::OsStrExt; - use windows::core::PCWSTR; - use windows::Win32::Foundation::{HANDLE, HWND}; - use windows::Win32::Security::WinTrust::{ - WinVerifyTrust, WINTRUST_ACTION_GENERIC_VERIFY_V2, WINTRUST_DATA, WINTRUST_DATA_0, - WINTRUST_FILE_INFO, WTD_CACHE_ONLY_URL_RETRIEVAL, WTD_CHOICE_FILE, - WTD_REVOCATION_CHECK_NONE, WTD_REVOKE_NONE, WTD_STATEACTION_IGNORE, WTD_UI_NONE, - }; - - if cfg!(debug_assertions) - && std::env::var_os("OPENBITFUN_ALLOW_UNSIGNED_MIGRATOR_DEV").as_deref() - == Some(OsStr::new("1")) - { - return Ok(()); - } - - let wide_path = path - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let mut file_info = WINTRUST_FILE_INFO { - cbStruct: size_of::() as u32, - pcwszFilePath: PCWSTR(wide_path.as_ptr()), - hFile: HANDLE::default(), - pgKnownSubject: std::ptr::null_mut(), - }; - let mut trust_data = WINTRUST_DATA { - cbStruct: size_of::() as u32, - dwUIChoice: WTD_UI_NONE, - fdwRevocationChecks: WTD_REVOKE_NONE, - dwUnionChoice: WTD_CHOICE_FILE, - Anonymous: WINTRUST_DATA_0 { - pFile: &mut file_info, - }, - dwStateAction: WTD_STATEACTION_IGNORE, - dwProvFlags: WTD_CACHE_ONLY_URL_RETRIEVAL | WTD_REVOCATION_CHECK_NONE, - ..Default::default() - }; - let mut policy = WINTRUST_ACTION_GENERIC_VERIFY_V2; - // SAFETY: all WinTrust structures and the NUL-terminated path remain live - // for the synchronous verification call; no state handle is retained. - let status = unsafe { - WinVerifyTrust( - HWND::default(), - &mut policy, - &mut trust_data as *mut _ as *mut c_void, - ) - }; - if status == 0 { - Ok(()) - } else { - Err(LegacyMigrationError::UntrustedExecutable( - path.to_path_buf(), - )) - } -} - -#[cfg(not(windows))] -fn platform_verify_executable(_path: &Path) -> LegacyMigrationResult<()> { - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -1026,6 +989,32 @@ mod tests { assert!(store.write_request(&expired, 100_000).is_err()); } + #[test] + fn inherited_job_retry_requires_debug_opt_in_and_permission_denied() { + use std::io::ErrorKind; + + assert!(should_retry_without_job_breakaway( + true, + true, + ErrorKind::PermissionDenied + )); + assert!(!should_retry_without_job_breakaway( + false, + true, + ErrorKind::PermissionDenied + )); + assert!(!should_retry_without_job_breakaway( + true, + false, + ErrorKind::PermissionDenied + )); + assert!(!should_retry_without_job_breakaway( + true, + true, + ErrorKind::NotFound + )); + } + #[test] fn process_classifier_keeps_caller_and_known_writers_only() { let processes = vec![ @@ -1071,14 +1060,6 @@ mod tests { assert!(!blockers[0].is_handoff_caller); } - struct AllowAll; - - impl ExecutableTrustVerifier for AllowAll { - fn verify(&self, _path: &Path) -> LegacyMigrationResult<()> { - Ok(()) - } - } - #[test] fn trusted_resolver_never_accepts_a_request_supplied_target_path() { let temporary = tempfile::tempdir().expect("temporary directory"); @@ -1095,7 +1076,6 @@ mod tests { ¤t, "openbitfun-data-migrator", "openbitfun-desktop", - &AllowAll, ) .expect("resolve trusted sibling"); assert_eq!( @@ -1106,7 +1086,6 @@ mod tests { ¤t, "openbitfun-data-migrator", "../attacker", - &AllowAll, ) .is_err()); } diff --git a/src/crates/services/legacy-migration/src/lib.rs b/src/crates/services/legacy-migration/src/lib.rs index 1846a3aef9..0a319fc003 100644 --- a/src/crates/services/legacy-migration/src/lib.rs +++ b/src/crates/services/legacy-migration/src/lib.rs @@ -1,5 +1,6 @@ //! Offline, source-read-only import primitives for legacy BitFun data. +mod diagnostics; mod engine; mod error; mod handoff; @@ -9,6 +10,7 @@ mod probe; mod sqlite; mod storage; +pub use diagnostics::{export_failure_diagnostics, release_observation}; pub use engine::{ compute_plan_hash, CancellationToken, CrashInjector, CrashPoint, DomainContext, DomainScan, LegacyDomainAdapter, MigrationEngine, NoCrashInjection, @@ -16,8 +18,8 @@ pub use engine::{ pub use error::{LegacyMigrationError, LegacyMigrationResult}; pub use handoff::{ blocking_writer_processes, blocking_writer_processes_for_product, launch_trusted_executable, - ExecutableTrustVerifier, HandoffDisposition, HandoffStore, PlatformExecutableTrustVerifier, - TrustedExecutable, TrustedInstallationResolver, ValidatedHandoff, WriterProcess, + HandoffDisposition, HandoffStore, TrustedExecutable, TrustedInstallationResolver, + ValidatedHandoff, WriterProcess, }; pub use onboarding::MigrationOnboardingStore; pub use paths::{MigrationRoots, LEGACY_PRODUCT_ID}; diff --git a/src/crates/services/legacy-migration/src/storage.rs b/src/crates/services/legacy-migration/src/storage.rs index ca2bc3de10..c7a0db1964 100644 --- a/src/crates/services/legacy-migration/src/storage.rs +++ b/src/crates/services/legacy-migration/src/storage.rs @@ -47,6 +47,14 @@ impl MigrationLayout { self.run_root().join("report.json") } + pub fn release_observation_path(&self) -> PathBuf { + self.run_root().join("release-observation.json") + } + + pub fn failure_diagnostics_path(&self) -> PathBuf { + self.run_root().join("failure-diagnostics.json") + } + pub fn stage_root(&self) -> PathBuf { self.run_root().join("stage") } @@ -172,22 +180,15 @@ fn replace_file(source: &Path, target: &Path) -> LegacyMigrationResult<()> { #[cfg(windows)] fn replace_file(source: &Path, target: &Path) -> LegacyMigrationResult<()> { - use std::os::windows::ffi::OsStrExt; use windows::core::PCWSTR; use windows::Win32::Storage::FileSystem::{ MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, }; - let source_wide = source - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let target_wide = target - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); + let source_wide = + windows_extended_path(source).map_err(|error| LegacyMigrationError::io(source, error))?; + let target_wide = + windows_extended_path(target).map_err(|error| LegacyMigrationError::io(target, error))?; unsafe { MoveFileExW( PCWSTR(source_wide.as_ptr()), @@ -197,3 +198,58 @@ fn replace_file(source: &Path, target: &Path) -> LegacyMigrationResult<()> { .map_err(|error| LegacyMigrationError::io(target, std::io::Error::other(error.to_string()))) } } + +#[cfg(windows)] +fn windows_extended_path(path: &Path) -> std::io::Result> { + use std::os::windows::ffi::OsStrExt; + + let absolute = std::path::absolute(path)?; + let path = absolute.as_os_str().encode_wide().collect::>(); + let slash = b'\\' as u16; + let mut extended = if path.starts_with(&[slash, slash, b'?' as u16, slash]) + || path.starts_with(&[slash, slash, b'.' as u16, slash]) + { + path + } else if path.starts_with(&[slash, slash]) { + r"\\?\UNC\" + .encode_utf16() + .chain(path.into_iter().skip(2)) + .collect() + } else if path.len() >= 3 && path[1] == b':' as u16 && path[2] == slash { + r"\\?\".encode_utf16().chain(path).collect() + } else { + path + }; + extended.push(0); + Ok(extended) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(windows)] + #[test] + fn atomic_write_supports_windows_paths_beyond_max_path() { + let temp = tempfile::Builder::new() + .prefix("openbitfun-legacy-migration-long-path-") + .tempdir() + .unwrap(); + let mut parent = temp.path().to_path_buf(); + while parent + .join("migration-owned-session-state.json") + .as_os_str() + .len() + < 270 + { + parent.push("workspace-session-runtime-segment"); + } + let target = parent.join("migration-owned-session-state.json"); + + atomic_write_bytes(&target, b"first").unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"first"); + + atomic_write_bytes(&target, b"second").unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"second"); + } +} diff --git a/src/crates/services/legacy-migration/tests/fixtures/support-matrix.json b/src/crates/services/legacy-migration/tests/fixtures/support-matrix.json index d6aa4affc4..fab4ab3f31 100644 --- a/src/crates/services/legacy-migration/tests/fixtures/support-matrix.json +++ b/src/crates/services/legacy-migration/tests/fixtures/support-matrix.json @@ -3,6 +3,7 @@ "sourceProduct": "bitfun", "supportedVersions": ">=0.2.0,<1.0.0", "canonicalFixtureVersion": "0.2.19", + "sourceRetentionPolicy": "never_delete_automatically", "domains": [ { "id": "settings_and_credentials", diff --git a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md index 293c1c8d52..eddb42ae05 100644 --- a/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md +++ b/src/crates/services/legacy-migration/tests/fixtures/v0.2.19/README.md @@ -15,6 +15,13 @@ that resumable runtime work state is rebuilt instead of imported. Supported source range for V1 is `>=0.2.0,<1.0.0`. The fixture's canonical source revision is `845b4b4d2925f7c41e7e03a4a618606fbd0da8b6`. +Additional canonical versions require evidence captured from a real retired +BitFun build; synthetic version labels must not expand the support matrix. + +The source-retention contract is `never_delete_automatically`. Engine and +owner-adapter tests compare source-root file hashes across successful, failed, +and cancelled runs; uninstalling BitFun or deleting its data remains a separate +explicit user action. All identities, paths, message text, tokens, host keys, and credentials are synthetic. Secret-bearing owners are represented only by metadata that forces diff --git a/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs b/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs index 200f138b66..ef12a7a0ac 100644 --- a/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs +++ b/src/crates/services/legacy-migration/tests/migration_engine_contracts.rs @@ -1,13 +1,14 @@ use openbitfun_legacy_migration::{ - atomic_write_json, probe_legacy_source, snapshot_sqlite_read_only, CancellationToken, - CrashInjector, CrashPoint, DomainContext, DomainScan, LegacyDomainAdapter, + atomic_write_json, export_failure_diagnostics, probe_legacy_source, snapshot_sqlite_read_only, + CancellationToken, CrashInjector, CrashPoint, DomainContext, DomainScan, LegacyDomainAdapter, LegacyMigrationError, LegacyMigrationResult, MigrationEngine, MigrationLayout, MigrationLock, MigrationRoots, NoCrashInjection, ProbeLimits, }; use openbitfun_product_domains::legacy_migration::{ FindingSeverity, MigrationDiagnostic, MigrationDomainId, MigrationDomainResult, - MigrationDomainState, MigrationGroupId, MigrationPhase, MigrationRunReport, MigrationRunStatus, - MigrationSelection, ScanFinding, + MigrationDomainState, MigrationGroupId, MigrationJournalEvent, MigrationPhase, + MigrationReleaseObservation, MigrationRunReport, MigrationRunStatus, MigrationSelection, + ScanFinding, }; use rusqlite::Connection; use sha2::{Digest, Sha256}; @@ -40,6 +41,7 @@ struct FakeAdapter { domain: MigrationDomainId, calls: Arc>>, finalize_behavior: FinalizeBehavior, + stage_error: Option, } impl LegacyDomainAdapter for FakeAdapter { @@ -67,6 +69,12 @@ impl LegacyDomainAdapter for FakeAdapter { fn stage(&self, context: &DomainContext<'_>) -> LegacyMigrationResult { self.update(|counts| counts.stage += 1); + if let Some(kind) = self.stage_error { + return Err(LegacyMigrationError::Io { + path: PathBuf::from("C:/Users/private/session-state.json"), + source: std::io::Error::new(kind, "private storage detail"), + }); + } atomic_write_json( &stage_path(context, self.domain), &serde_json::json!({"domain": format!("{:?}", self.domain), "entities": ["stable-id"]}), @@ -170,6 +178,7 @@ fn engine_recovers_after_commit_before_journal_and_deduplicates_repeated_runs() let temp = test_tempdir(); let roots = fixture_roots(temp.path()); seed_supported_source(&roots); + let source_before = legacy_source_snapshot(&roots); let source = probe_legacy_source(&roots, ProbeLimits::default()) .expect("probe should succeed") .expect("source should be present"); @@ -219,7 +228,10 @@ fn engine_recovers_after_commit_before_journal_and_deduplicates_repeated_runs() 2, "the owner idempotently retries an ambiguous commit" ); - assert!(roots.legacy_user_root.join("config/app.json").exists()); + let observation = read_observation(&roots, &plan.run_id); + assert_eq!(observation.result_code, "migration_completed"); + assert_eq!(observation.failure_phase, None); + assert_eq!(legacy_source_snapshot(&roots), source_before); } #[test] @@ -270,6 +282,10 @@ fn engine_persists_owner_metadata_finalized_after_commit_validation() { .finalize_result, 1 ); + assert_eq!( + read_observation(&roots, &plan.run_id).result_code, + "migration_completed_with_warnings" + ); let persisted: MigrationRunReport = serde_json::from_slice( &fs::read(MigrationLayout::new(&roots, &plan.run_id).report_path()) @@ -337,6 +353,7 @@ fn engine_rolls_back_unverified_commit_when_owner_finalization_fails() { let temp = test_tempdir(); let roots = fixture_roots(temp.path()); seed_supported_source(&roots); + let source_before = legacy_source_snapshot(&roots); let source = probe_legacy_source(&roots, ProbeLimits::default()) .expect("probe should succeed") .expect("source should be present"); @@ -385,6 +402,259 @@ fn engine_rolls_back_unverified_commit_when_owner_finalization_fails() { .state, MigrationDomainState::Failed ); + let observation = read_observation(&roots, &plan.run_id); + assert_eq!(observation.result_code, "domain_failed_recoverable"); + assert_eq!( + observation.failure_phase, + Some(MigrationPhase::ValidateCommit) + ); + assert_eq!(legacy_source_snapshot(&roots), source_before); +} + +#[test] +fn domain_failure_report_keeps_only_sanitized_storage_classification() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + seed_supported_source(&roots); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .expect("probe should succeed") + .expect("source should be present"); + let calls = Arc::new(Mutex::new(BTreeMap::new())); + let engine = + fake_engine_with_stage_error(roots.clone(), calls, std::io::ErrorKind::PermissionDenied); + let plan = engine + .plan( + &source, + MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }, + &CancellationToken::default(), + ) + .expect("plan should succeed"); + + assert!(matches!( + engine.execute(&plan, &CancellationToken::default(), &NoCrashInjection), + Err(LegacyMigrationError::Domain { + domain: MigrationDomainId::Settings, + .. + }) + )); + + let report_bytes = fs::read(MigrationLayout::new(&roots, &plan.run_id).report_path()) + .expect("failed report should be persisted"); + let report: MigrationRunReport = + serde_json::from_slice(&report_bytes).expect("failed report should be valid"); + let diagnostic = report + .diagnostics + .last() + .expect("failed report should include a diagnostic"); + assert_eq!(diagnostic.code, "domain_io_permission_denied"); + assert_eq!(diagnostic.domain, Some(MigrationDomainId::Settings)); + assert_eq!(diagnostic.severity, FindingSeverity::Blocking); + assert!(diagnostic.relative_path.is_none()); + let serialized = String::from_utf8(report_bytes).unwrap(); + assert!(!serialized.contains("private")); + assert!(!serialized.contains("session-state")); +} + +#[test] +fn cancellation_keeps_every_legacy_source_root_unchanged() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + seed_supported_source(&roots); + fs::create_dir_all(&roots.legacy_home_root).expect("legacy home should be created"); + fs::write( + roots.legacy_home_root.join("user-content.txt"), + "content that must remain untouched", + ) + .expect("legacy content should be written"); + let source_before = legacy_source_snapshot(&roots); + let source = probe_legacy_source(&roots, ProbeLimits::default()) + .expect("probe should succeed") + .expect("source should be present"); + let engine = fake_engine(roots.clone(), Arc::new(Mutex::new(BTreeMap::new()))); + let plan = engine + .plan( + &source, + MigrationSelection { + groups: BTreeSet::from([MigrationGroupId::SettingsAndCredentials]), + }, + &CancellationToken::default(), + ) + .expect("plan should succeed"); + let cancellation = CancellationToken::default(); + let cancellation_from_progress = cancellation.clone(); + + let result = + engine.execute_with_progress(&plan, &cancellation, &NoCrashInjection, move |event| { + if event.phase == MigrationPhase::Stage { + cancellation_from_progress.cancel(); + } + }); + + assert!(matches!(result, Err(LegacyMigrationError::Cancelled))); + assert_eq!(legacy_source_snapshot(&roots), source_before); + let layout = MigrationLayout::new(&roots, &plan.run_id); + let report: MigrationRunReport = serde_json::from_slice( + &fs::read(layout.report_path()).expect("cancelled report should be persisted"), + ) + .expect("cancelled report should be valid"); + assert_eq!(report.status, MigrationRunStatus::Cancelled); + let observation: MigrationReleaseObservation = serde_json::from_slice( + &fs::read(layout.release_observation_path()) + .expect("cancelled observation should be persisted"), + ) + .expect("cancelled observation should be valid"); + assert_eq!(observation.result_code, "migration_cancelled"); + assert_eq!(observation.failure_phase, None); +} + +#[test] +fn release_observation_and_failure_export_exclude_sensitive_content() { + let temp = test_tempdir(); + let roots = fixture_roots(temp.path()); + let layout = MigrationLayout::new(&roots, "diagnostics-run"); + layout.initialize().expect("layout should initialize"); + for event in [ + MigrationJournalEvent { + format_version: 1, + sequence: 1, + recorded_at_ms: 1_100, + run_id: "private-run-id".to_string(), + status: MigrationRunStatus::Staging, + phase: MigrationPhase::Stage, + domain: Some(MigrationDomainId::Settings), + domain_state: Some(MigrationDomainState::Staged), + code: "journal-secret C:/Users/Alice".to_string(), + }, + MigrationJournalEvent { + format_version: 1, + sequence: 2, + recorded_at_ms: 1_250, + run_id: "private-run-id".to_string(), + status: MigrationRunStatus::FailedRecoverable, + phase: MigrationPhase::ValidateStage, + domain: Some(MigrationDomainId::Settings), + domain_state: Some(MigrationDomainState::Failed), + code: "domain_failed_recoverable".to_string(), + }, + ] { + layout + .append_journal(&event) + .expect("journal event should append"); + } + fs::write( + layout.journal_path(), + [ + fs::read(layout.journal_path()).expect("journal should be readable"), + b"{\"invalid\":\"message-body-secret\"\n".to_vec(), + ] + .concat(), + ) + .expect("invalid diagnostic fixture should append"); + let report = MigrationRunReport { + format_version: 1, + run_id: "private-run-id".to_string(), + source_fingerprint: "private-source-fingerprint".to_string(), + plan_hash: "private-plan-hash".to_string(), + status: MigrationRunStatus::FailedRecoverable, + started_at_ms: 1_000, + domain_results: vec![MigrationDomainResult { + domain: MigrationDomainId::Settings, + state: MigrationDomainState::Failed, + imported: 42, + warnings: vec![MigrationDiagnostic { + code: "settings_validation_failed".to_string(), + severity: FindingSeverity::Blocking, + domain: Some(MigrationDomainId::Settings), + relative_path: Some("C:/Users/Alice/secret.txt".to_string()), + message: "message-body-secret".to_string(), + action: Some("repair-account-secret".to_string()), + }], + requires_reauthentication: vec!["repair-account-secret".to_string()], + ..MigrationDomainResult::default() + }], + diagnostics: vec![MigrationDiagnostic { + code: "diagnostic secret token".to_string(), + severity: FindingSeverity::Warning, + domain: None, + relative_path: Some("../private-path".to_string()), + message: "another-private-message".to_string(), + action: Some("private-action".to_string()), + }], + ..MigrationRunReport::default() + }; + + let path = + export_failure_diagnostics(&layout, &report).expect("failure diagnostics should export"); + assert_eq!(path, layout.failure_diagnostics_path()); + let bytes = fs::read(&path).expect("failure diagnostics should be readable"); + let serialized = String::from_utf8(bytes.clone()).expect("diagnostics should be UTF-8"); + for forbidden in [ + "private-run-id", + "private-source-fingerprint", + "private-plan-hash", + "Users/Alice", + "message-body-secret", + "repair-account-secret", + "another-private-message", + "private-action", + "private-path", + "journal-secret", + "42", + ] { + assert!( + !serialized.contains(forbidden), + "diagnostics exposed forbidden value: {forbidden}" + ); + } + let value: serde_json::Value = + serde_json::from_slice(&bytes).expect("diagnostics should be valid JSON"); + assert_object_keys( + &value, + &["diagnosticCodes", "formatVersion", "journal", "observation"], + ); + assert_object_keys( + &value["observation"], + &["domainStates", "durationMs", "failurePhase", "resultCode"], + ); + for domain_state in value["observation"]["domainStates"] + .as_array() + .expect("domain states should be an array") + { + assert_object_keys(domain_state, &["domain", "state"]); + } + for diagnostic in value["diagnosticCodes"] + .as_array() + .expect("diagnostic codes should be an array") + { + assert_object_keys(diagnostic, &["code", "domain", "severity"]); + } + for journal_entry in value["journal"] + .as_array() + .expect("journal should be an array") + { + assert_object_keys( + journal_entry, + &[ + "code", + "domain", + "domainState", + "phase", + "sequence", + "status", + ], + ); + } + assert_eq!(value["observation"]["durationMs"], 250); + assert_eq!(value["observation"]["failurePhase"], "validate_stage"); + assert_eq!( + value["observation"]["resultCode"], + "domain_failed_recoverable" + ); + assert!(serialized.contains("settings_validation_failed")); + assert!(serialized.contains("journal_entry_invalid")); + assert!(serialized.contains("redacted_code")); } #[test] @@ -461,6 +731,28 @@ fn fake_engine_with_finalize( roots: MigrationRoots, calls: Arc>>, settings_finalize_behavior: FinalizeBehavior, +) -> MigrationEngine { + fake_engine_with_behaviors(roots, calls, settings_finalize_behavior, None) +} + +fn fake_engine_with_stage_error( + roots: MigrationRoots, + calls: Arc>>, + settings_stage_error: std::io::ErrorKind, +) -> MigrationEngine { + fake_engine_with_behaviors( + roots, + calls, + FinalizeBehavior::PassThrough, + Some(settings_stage_error), + ) +} + +fn fake_engine_with_behaviors( + roots: MigrationRoots, + calls: Arc>>, + settings_finalize_behavior: FinalizeBehavior, + settings_stage_error: Option, ) -> MigrationEngine { let adapters = [ MigrationDomainId::Settings, @@ -477,6 +769,9 @@ fn fake_engine_with_finalize( } else { FinalizeBehavior::PassThrough }, + stage_error: (domain == MigrationDomainId::Settings) + .then_some(settings_stage_error) + .flatten(), }) as Box }); MigrationEngine::new(roots, adapters).expect("fake engine should be valid") @@ -544,6 +839,72 @@ fn sha256_file(path: &Path) -> String { hex::encode(Sha256::digest(bytes)) } +fn legacy_source_snapshot(roots: &MigrationRoots) -> BTreeMap { + let mut snapshot = BTreeMap::new(); + for (name, root) in [ + ("user", &roots.legacy_user_root), + ("home", &roots.legacy_home_root), + ("skills", &roots.legacy_skills_root), + ("ssh", &roots.legacy_ssh_root), + ] { + collect_source_entries(name, root, root, &mut snapshot); + } + snapshot +} + +fn collect_source_entries( + name: &str, + root: &Path, + current: &Path, + snapshot: &mut BTreeMap, +) { + if !current.exists() { + return; + } + let relative = current + .strip_prefix(root) + .expect("source entry should remain below its root") + .to_string_lossy() + .replace('\\', "/"); + let key = if relative.is_empty() { + name.to_string() + } else { + format!("{name}/{relative}") + }; + if current.is_dir() { + snapshot.insert(format!("{key}/"), "directory".to_string()); + let mut entries = fs::read_dir(current) + .expect("source directory should remain readable") + .collect::, _>>() + .expect("source entries should remain readable"); + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + collect_source_entries(name, root, &entry.path(), snapshot); + } + } else { + snapshot.insert(key, sha256_file(current)); + } +} + +fn assert_object_keys(value: &serde_json::Value, expected: &[&str]) { + let mut actual = value + .as_object() + .expect("value should be a JSON object") + .keys() + .map(String::as_str) + .collect::>(); + actual.sort_unstable(); + assert_eq!(actual, expected); +} + +fn read_observation(roots: &MigrationRoots, run_id: &str) -> MigrationReleaseObservation { + serde_json::from_slice( + &fs::read(MigrationLayout::new(roots, run_id).release_observation_path()) + .expect("release observation should be persisted"), + ) + .expect("release observation should be valid") +} + fn test_tempdir() -> tempfile::TempDir { match std::env::var_os("OPENBITFUN_TEST_TMPDIR") { Some(root) => tempfile::Builder::new() diff --git a/src/crates/services/services-core/src/process_manager.rs b/src/crates/services/services-core/src/process_manager.rs index ad88966cef..fca3ca82bd 100644 --- a/src/crates/services/services-core/src/process_manager.rs +++ b/src/crates/services/services-core/src/process_manager.rs @@ -165,15 +165,35 @@ pub fn create_tokio_command>(program: S) -> TokioComma /// the executable from an authenticated installation boundary; this helper /// only supplies lifecycle and no-console-window behavior. pub fn create_detached_command>(program: S) -> Command { + let mut command = create_handoff_command(program); + + #[cfg(windows)] + command.creation_flags(detached_creation_flags()); + + command +} + +/// Create a hidden Windows handoff process group without requesting Job +/// breakaway. +/// +/// The child keeps the caller's Job association when one exists. This is a +/// development-only fallback for hosts whose outer Job rejects explicit +/// breakaway; callers must not treat it as equivalent to a detached handoff. +#[cfg(windows)] +pub fn create_inherited_job_process_group_command>( + program: S, +) -> Command { + let mut command = create_handoff_command(program); + command.creation_flags(inherited_job_process_group_creation_flags()); + command +} + +fn create_handoff_command>(program: S) -> Command { let mut command = Command::new(program.as_ref()); command .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); - - #[cfg(windows)] - command.creation_flags(detached_creation_flags()); - command } @@ -182,6 +202,11 @@ const fn detached_creation_flags() -> u32 { CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB } +#[cfg(windows)] +const fn inherited_job_process_group_creation_flags() -> u32 { + CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP +} + #[cfg(target_os = "macos")] fn apply_cached_macos_path(cmd: &mut TokioCommand) { if let Some(path) = cached_macos_path_env() { @@ -258,4 +283,12 @@ mod tests { assert_ne!(flags & CREATE_NEW_PROCESS_GROUP, 0); assert_ne!(flags & CREATE_BREAKAWAY_FROM_JOB, 0); } + + #[test] + fn inherited_job_handoff_is_hidden_without_requesting_breakaway() { + let flags = inherited_job_process_group_creation_flags(); + assert_ne!(flags & CREATE_NO_WINDOW, 0); + assert_ne!(flags & CREATE_NEW_PROCESS_GROUP, 0); + assert_eq!(flags & CREATE_BREAKAWAY_FROM_JOB, 0); + } } diff --git a/src/crates/services/services-integrations/src/remote_persistence.rs b/src/crates/services/services-integrations/src/remote_persistence.rs index 7e48fad32f..3c7bda28c3 100644 --- a/src/crates/services/services-integrations/src/remote_persistence.rs +++ b/src/crates/services/services-integrations/src/remote_persistence.rs @@ -711,17 +711,18 @@ pub fn read_current_remote_workspaces(path: &Path) -> Result Result>> { - #[derive(Deserialize)] - #[serde(untagged)] - enum LegacyShape { - One(RemoteWorkspaceRecord), - Many(Vec), - } - let shape: Option = read_optional_json(path)?; - let values = shape.map(|shape| match shape { - LegacyShape::One(value) => vec![value], - LegacyShape::Many(values) => values, - }); + let value: Option = read_optional_json(path)?; + let values = value + .map(|value| match value { + serde_json::Value::Array(_) => { + serde_json::from_value(value).context("parse legacy remote workspace array") + } + serde_json::Value::Object(_) => serde_json::from_value(value) + .map(|value| vec![value]) + .context("parse legacy remote workspace object"), + _ => bail!("legacy remote workspace must be a JSON object or array"), + }) + .transpose()?; if let Some(values) = &values { validate_remote_workspaces(values)?; } @@ -1200,6 +1201,21 @@ mod tests { assert!(!format!("{loaded:?}").contains("fixture-password")); } + #[test] + fn ssh_owner_treats_an_empty_legacy_workspace_array_as_no_workspaces() { + let root = test_tempdir("ssh-empty-workspaces"); + let workspace_path = root.path().join("remote_workspace.json"); + std::fs::write(&workspace_path, "[]").unwrap(); + + assert_eq!( + read_legacy_remote_workspaces(&workspace_path).unwrap(), + Some(Vec::new()) + ); + + std::fs::write(&workspace_path, "{}").unwrap(); + assert!(read_legacy_remote_workspaces(&workspace_path).is_err()); + } + fn write_legacy_session_fixture( directory: &Path, binding: &MachineBinding, diff --git a/src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts index 9e189063af..cbc53c4fd0 100644 --- a/src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts +++ b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts @@ -33,7 +33,10 @@ describe('legacy migration startup notification', () => { }); it('loads the lazy namespace and shows a completed report once', async () => { - mocks.getStatus.mockResolvedValue({ startupReport: { runId: 'run-1', status: 'completed' } }); + mocks.getStatus.mockResolvedValue({ + startupError: null, + startupReport: { runId: 'run-1', status: 'completed' }, + }); const values = new Map(); const storage = { getItem: (key: string) => values.get(key) ?? null, @@ -51,4 +54,34 @@ describe('legacy migration startup notification', () => { status: 'completed', }); }); + + it('prioritizes a redacted startup launch error over an older report', async () => { + mocks.getStatus.mockResolvedValue({ + startupError: { + code: 'data_migrator_launch_failed', + message: 'A redacted backend message', + recoverable: true, + }, + startupReport: { runId: 'run-1', status: 'completed' }, + }); + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); }, + }; + + await showLegacyMigrationStartupNotification(storage); + await showLegacyMigrationStartupNotification(storage); + + expect(mocks.error).toHaveBeenCalledTimes(1); + expect(mocks.error.mock.calls[0][0]).toBe( + 'settings/legacy-migration:startupNotification.launchFailed', + ); + expect(mocks.error.mock.calls[0][1].metadata).toEqual({ + source: 'legacy-migration-startup-error', + code: 'data_migrator_launch_failed', + recoverable: true, + }); + expect(mocks.success).not.toHaveBeenCalled(); + }); }); diff --git a/src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts index c169629eef..7bb3b2cb10 100644 --- a/src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts +++ b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts @@ -13,27 +13,56 @@ export async function showLegacyMigrationStartupNotification( storage: Pick = sessionStorage, ): Promise { const status = await legacyMigrationAPI.getStatus(); + const startupError = status.startupError; const report = status.startupReport; - if (!report) return; - - const noticeKey = `openbitfun:legacy-migration-notice:${report.runId}`; - if (storage.getItem(noticeKey) === 'shown') return; + if (!startupError && !report) return; await i18nService.loadNamespace('settings/legacy-migration'); - storage.setItem(noticeKey, 'shown'); const namespace = 'settings/legacy-migration'; - const openReport = () => { + const openMigrationSettings = () => { void import('@/shared/services/ide-control').then(({ quickActions }) => { quickActions.openSettings({ pageId: 'data.migration' }); }); }; + + if (startupError) { + const noticeKey = `openbitfun:legacy-migration-startup-error:${startupError.code}`; + if (storage.getItem(noticeKey) === 'shown') return; + + storage.setItem(noticeKey, 'shown'); + notificationService.error( + i18nService.t(`${namespace}:startupNotification.launchFailed`), + { + title: i18nService.t(`${namespace}:startupNotification.title`), + duration: 0, + actions: [{ + label: i18nService.t(`${namespace}:startupNotification.openSettings`), + variant: 'primary' as const, + onClick: openMigrationSettings, + }], + metadata: { + source: 'legacy-migration-startup-error', + code: startupError.code, + recoverable: startupError.recoverable, + }, + }, + ); + return; + } + + if (!report) return; + + const noticeKey = `openbitfun:legacy-migration-notice:${report.runId}`; + if (storage.getItem(noticeKey) === 'shown') return; + + storage.setItem(noticeKey, 'shown'); const options = { title: i18nService.t(`${namespace}:startupNotification.title`), duration: 0, actions: [{ label: i18nService.t(`${namespace}:actions.viewReport`), variant: 'primary' as const, - onClick: openReport, + onClick: openMigrationSettings, }], metadata: { source: 'legacy-migration-startup-result', diff --git a/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.ts b/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.ts index 3f89545462..f2e5ecf3a5 100644 --- a/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/LegacyMigrationAPI.ts @@ -114,11 +114,18 @@ export interface MigrationRunReport { requiresRelocation: string[]; } +export interface LegacyMigrationStartupError { + code: string; + message: string; + recoverable: boolean; +} + export interface LegacyMigrationStatusView { source: LegacySourceDescriptor | null; onboarding: MigrationOnboardingState; latestReport: MigrationRunReport | null; startupReport: MigrationRunReport | null; + startupError?: LegacyMigrationStartupError | null; } export interface ScanFinding { diff --git a/src/web-ui/src/locales/en-US/settings/legacy-migration.json b/src/web-ui/src/locales/en-US/settings/legacy-migration.json index c88e07a98d..8b4765a7c7 100644 --- a/src/web-ui/src/locales/en-US/settings/legacy-migration.json +++ b/src/web-ui/src/locales/en-US/settings/legacy-migration.json @@ -117,6 +117,8 @@ }, "startupNotification": { "title": "BitFun data migration", + "launchFailed": "OpenBitFun continued to start, but the Data Migrator could not be launched. Existing OpenBitFun and BitFun data were not changed.", + "openSettings": "Open migration settings", "statuses": { "discovered": "The Data Migrator discovered old data but did not finish a migration.", "scanned": "The Data Migrator completed a read-only scan.", diff --git a/src/web-ui/src/locales/zh-CN/settings/legacy-migration.json b/src/web-ui/src/locales/zh-CN/settings/legacy-migration.json index 773c777fff..d89e55cf8b 100644 --- a/src/web-ui/src/locales/zh-CN/settings/legacy-migration.json +++ b/src/web-ui/src/locales/zh-CN/settings/legacy-migration.json @@ -105,6 +105,8 @@ }, "startupNotification": { "title": "BitFun 数据迁移", + "launchFailed": "OpenBitFun 已继续启动,但无法启动 Data Migrator。现有 OpenBitFun 和 BitFun 数据均未更改。", + "openSettings": "打开迁移设置", "statuses": { "discovered": "Data Migrator 发现了旧数据,但尚未完成迁移。", "scanned": "Data Migrator 已完成只读扫描。", diff --git a/src/web-ui/src/locales/zh-TW/settings/legacy-migration.json b/src/web-ui/src/locales/zh-TW/settings/legacy-migration.json index b965724f89..948f5ccbd9 100644 --- a/src/web-ui/src/locales/zh-TW/settings/legacy-migration.json +++ b/src/web-ui/src/locales/zh-TW/settings/legacy-migration.json @@ -105,6 +105,8 @@ }, "startupNotification": { "title": "BitFun 資料遷移", + "launchFailed": "OpenBitFun 已繼續啟動,但無法啟動 Data Migrator。現有 OpenBitFun 和 BitFun 資料均未變更。", + "openSettings": "開啟遷移設定", "statuses": { "discovered": "Data Migrator 發現了舊資料,但尚未完成遷移。", "scanned": "Data Migrator 已完成唯讀掃描。", From d6fa7a75e480d8a0a5727745aaa02531964e5ae9 Mon Sep 17 00:00:00 2001 From: wsp Date: Tue, 8 Sep 2026 01:09:06 +0800 Subject: [PATCH 6/7] fix(migration): Clarify reports and dev startup Successful migrations showed warnings for expected exclusions, while View Report only refreshed an already displayed summary. Development startup also required a manually built Migrator and an environment flag. - Open report details with per-domain results and grouped explanations. - Treat excluded session paths and orphaned parent relationships as informational, and count warnings by their actual severity. - Exclude normal built-in MiniApp storage imports from conflict counts while preserving existing target-conflict handling. - Simplify migration instructions in English and both Chinese locales. - Automatically retry denied Windows Job breakaway in Debug builds without an environment flag; retain Release launch behavior. - Incrementally build Data Migrator before Desktop dev and preview startup, and stop with an error if preparation fails. Validation: focused migration tests, Web and locale checks, Migrator script checks, and dev script syntax checks passed. Application startup and UI interaction were not exercised. --- .../src-tauri/src/installer/commands.rs | 6 +-- OpenBitFun-Installer/src/i18n/locales/en.json | 2 +- .../src/i18n/locales/zh-TW.json | 2 +- OpenBitFun-Installer/src/i18n/locales/zh.json | 2 +- .../capabilities.json | 54 +++++++++---------- .../capabilities/setting.data.migration.md | 12 ++--- .../technical/product-control-open-audit.json | 22 ++++---- .../technical/tauri-command-map.json | 2 +- scripts/dev.cjs | 17 ++++++ scripts/product-identity-audit.mjs | 4 ++ scripts/product-identity-audit.test.mjs | 15 ++++++ src/apps/data-migrator/ui/app.js | 38 ++++++------- src/apps/desktop/src/lib.rs | 2 +- .../legacy_migration/agent_coordination.rs | 17 ++++-- .../core/src/legacy_migration/extensions.rs | 13 +++-- .../core/src/legacy_migration/memory.rs | 2 +- .../core/src/legacy_migration/settings.rs | 2 +- .../legacy_migration/workspace_sessions.rs | 11 ++-- .../generated/product-control-catalog.json | 54 +++++++++---------- .../services/legacy-migration/src/engine.rs | 12 +++-- .../services/legacy-migration/src/handoff.rs | 22 ++------ .../interactive-capabilities/catalog.json | 36 ++++++------- .../generated/interactive-capabilities.json | 54 +++++++++---------- .../app/scenes/settings/settingsRegistry.ts | 2 +- ...legacyMigrationStartupNotification.test.ts | 5 +- .../legacyMigrationStartupNotification.ts | 12 ++--- .../api/generated/productControl.ts | 2 +- .../LegacyMigrationSettingsPage.tsx | 35 +++++++++++- src/web-ui/src/locales/en-US/settings.json | 2 +- .../en-US/settings/legacy-migration.json | 5 ++ src/web-ui/src/locales/zh-CN/settings.json | 2 +- .../zh-CN/settings/legacy-migration.json | 5 ++ src/web-ui/src/locales/zh-TW/settings.json | 2 +- .../zh-TW/settings/legacy-migration.json | 5 ++ 34 files changed, 284 insertions(+), 194 deletions(-) diff --git a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs index d66a9c4130..b53102c14e 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs @@ -872,7 +872,7 @@ pub(crate) fn launch_legacy_data_migrator( { let _ = request; return Err( - "Legacy BitFun data migration is not supported by this Installer platform.".to_string(), + "Legacy data migration is not supported by this Installer platform.".to_string(), ); } @@ -882,13 +882,13 @@ pub(crate) fn launch_legacy_data_migrator( let roots = MigrationRoots::resolve_current_user() .map_err(|_| "Could not resolve the current-user migration storage.".to_string())?; let Some(source) = probe_legacy_source(&roots, ProbeLimits::default()) - .map_err(|_| "Could not safely inspect older BitFun data.".to_string())? + .map_err(|_| "Could not safely inspect older data.".to_string())? else { return Ok(false); }; if !source.supported { return Err( - "The discovered BitFun data format is not supported by this Data Migrator." + "The discovered legacy data format is not supported by this Data Migrator." .to_string(), ); } diff --git a/OpenBitFun-Installer/src/i18n/locales/en.json b/OpenBitFun-Installer/src/i18n/locales/en.json index b3394b06bc..afd0daf843 100644 --- a/OpenBitFun-Installer/src/i18n/locales/en.json +++ b/OpenBitFun-Installer/src/i18n/locales/en.json @@ -226,7 +226,7 @@ }, "complete": { "finish": "Finish", - "migrateLegacyData": "Migrate old BitFun data after installation", + "migrateLegacyData": "Migrate legacy data after installation", "migrateLegacyDataDescription": "Starts the installed Data Migrator. It keeps the old data unchanged, asks you to confirm the scope, and reopens OpenBitFun when migration ends." }, "uninstall": { diff --git a/OpenBitFun-Installer/src/i18n/locales/zh-TW.json b/OpenBitFun-Installer/src/i18n/locales/zh-TW.json index 1ea4429e53..cd72d342e7 100644 --- a/OpenBitFun-Installer/src/i18n/locales/zh-TW.json +++ b/OpenBitFun-Installer/src/i18n/locales/zh-TW.json @@ -226,7 +226,7 @@ }, "complete": { "finish": "完成", - "migrateLegacyData": "安裝完成後遷移舊 BitFun 資料", + "migrateLegacyData": "安裝完成後遷移舊 舊版資料", "migrateLegacyDataDescription": "啟動已安裝的 Data Migrator。它會保持舊資料不變,請你確認遷移範圍,並在遷移結束後重新開啟 OpenBitFun。" }, "uninstall": { diff --git a/OpenBitFun-Installer/src/i18n/locales/zh.json b/OpenBitFun-Installer/src/i18n/locales/zh.json index 3c476ab074..7888d8e19c 100644 --- a/OpenBitFun-Installer/src/i18n/locales/zh.json +++ b/OpenBitFun-Installer/src/i18n/locales/zh.json @@ -226,7 +226,7 @@ }, "complete": { "finish": "完成", - "migrateLegacyData": "安装完成后迁移旧 BitFun 数据", + "migrateLegacyData": "安装完成后迁移旧版 数据", "migrateLegacyDataDescription": "启动已安装的 Data Migrator。它会保持旧数据不变,请你确认迁移范围,并在迁移结束后重新打开 OpenBitFun。" }, "uninstall": { diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 403e22e76f..653efc45a6 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "cf1a4fae19862b0613cb1144cead8fcaaa7559b47d5a5f0f3a2c361be991083b", + "digest": "c77c16c414ce9fe50929fe249d900489fdceedb8e646aaaca03893aa33d4fbc8", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -29850,42 +29850,42 @@ "categoryId": "data", "titleZh": "旧版数据迁移", "titleEn": "Legacy data migration", - "summaryZh": "从本机旧版 BitFun 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", - "summaryEn": "Scan and import supported data from a local legacy BitFun installation, inspect redacted reports, and leave the legacy source unchanged.", + "summaryZh": "从本机旧版 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", + "summaryEn": "Scan and import supported data from a local legacy installation, inspect redacted reports, and leave the legacy source unchanged.", "keywordsZh": [ "旧版数据迁移", - "BitFun 数据", + "旧版数据", "迁移报告", "导入旧数据", "Data Migrator" ], "keywordsEn": [ "legacy data migration", - "BitFun data", + "legacy data", "migration report", "import old data", "Data Migrator" ], "highlightsZh": [ - "只读扫描本机旧版 BitFun 数据", + "只读扫描本机旧版数据", "按五个高层数据组选择迁移范围", "通过独立 Data Migrator 导入并查看去敏报告" ], "highlightsEn": [ - "Read-only scan of local legacy BitFun data", + "Read-only scan of local legacy data", "Choose migration scope across five high-level data groups", "Import through the standalone Data Migrator and inspect redacted reports" ], "items": [ { "id": "scan", - "titleZh": "只读扫描本机旧版 BitFun 来源及所选数据组", - "titleEn": "Read-only scan the local legacy BitFun source and selected data groups", + "titleZh": "只读扫描本机旧版数据来源及所选数据组", + "titleEn": "Read-only scan the local legacy source and selected data groups", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", - "reasonEn": "Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." + "reasonZh": "“只读扫描本机旧版数据来源及所选数据组”:扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Read-only scan the local legacy source and selected data groups: Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." } }, { @@ -29906,8 +29906,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", - "reasonEn": "Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." + "reasonZh": "“确认关闭影响后启动独立 Data Migrator”:启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launch the standalone Data Migrator after confirming shutdown impact: Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." } }, { @@ -29917,8 +29917,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", - "reasonEn": "Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." + "reasonZh": "“查看最近运行结果和各领域去敏状态”:报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Inspect the latest run result and redacted per-domain status: Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." } }, { @@ -29928,8 +29928,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", - "reasonEn": "The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." + "reasonZh": "“恢复已关闭的首次启动迁移提醒”:提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "Restore the first-start migration reminder after it was disabled: The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." } } ], @@ -29945,11 +29945,11 @@ ], "agentExamplesZh": [ "打开旧版数据迁移", - "带我查看 BitFun 迁移报告" + "带我查看数据迁移报告" ], "agentExamplesEn": [ "Open legacy data migration", - "Show me the BitFun migration report" + "Show me the data migration report" ], "destination": { "kind": "settings", @@ -29963,22 +29963,22 @@ "Legacy data migration", "数据与诊断", "Data & diagnostics", - "BitFun 数据", + "旧版数据", "迁移报告", "导入旧数据", "Data Migrator", "legacy data migration", - "BitFun data", + "legacy data", "migration report", "import old data", - "只读扫描本机旧版 BitFun 数据", + "只读扫描本机旧版数据", "按五个高层数据组选择迁移范围", "通过独立 Data Migrator 导入并查看去敏报告", - "Read-only scan of local legacy BitFun data", + "Read-only scan of local legacy data", "Choose migration scope across five high-level data groups", "Import through the standalone Data Migrator and inspect redacted reports", - "只读扫描本机旧版 BitFun 来源及所选数据组", - "Read-only scan the local legacy BitFun source and selected data groups", + "只读扫描本机旧版数据来源及所选数据组", + "Read-only scan the local legacy source and selected data groups", "选择设置、扩展、会话、记忆和远程连接迁移范围", "Choose settings, extensions, sessions, memory, and remote-connection migration scope", "确认关闭影响后启动独立 Data Migrator", @@ -29988,9 +29988,9 @@ "恢复已关闭的首次启动迁移提醒", "Restore the first-start migration reminder after it was disabled", "打开旧版数据迁移", - "带我查看 BitFun 迁移报告", + "带我查看数据迁移报告", "Open legacy data migration", - "Show me the BitFun migration report" + "Show me the data migration report" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.migration/" }, diff --git a/docs/interactive-capabilities/capabilities/setting.data.migration.md b/docs/interactive-capabilities/capabilities/setting.data.migration.md index f30972a30d..a8039fde4c 100644 --- a/docs/interactive-capabilities/capabilities/setting.data.migration.md +++ b/docs/interactive-capabilities/capabilities/setting.data.migration.md @@ -11,14 +11,14 @@ title_en: "Legacy data migration" > 设置 / Setting -从本机旧版 BitFun 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。 +从本机旧版 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。 -Scan and import supported data from a local legacy BitFun installation, inspect redacted reports, and leave the legacy source unchanged. +Scan and import supported data from a local legacy installation, inspect redacted reports, and leave the legacy source unchanged. ## 完整功能清单 / Everything included -- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 只读扫描本机旧版 BitFun 来源及所选数据组 - - Read-only scan the local legacy BitFun source and selected data groups +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 只读扫描本机旧版数据来源及所选数据组 + - Read-only scan the local legacy source and selected data groups - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 选择设置、扩展、会话、记忆和远程连接迁移范围 - Choose settings, extensions, sessions, memory, and remote-connection migration scope - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 确认关闭影响后启动独立 Data Migrator @@ -55,8 +55,8 @@ Scan and import supported data from a local legacy BitFun installation, inspect - “打开旧版数据迁移” - “Open legacy data migration” -- “带我查看 BitFun 迁移报告” - - “Show me the BitFun migration report” +- “带我查看数据迁移报告” + - “Show me the data migration report” Agent 会先查找相关功能或设置,确认目标后再替你打开、执行或修改。完整能力目录不会预先塞进对话上下文。 diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index 0e03ca71ae..1839909c20 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "cf1a4fae19862b0613cb1144cead8fcaaa7559b47d5a5f0f3a2c361be991083b", + "catalogDigest": "c77c16c414ce9fe50929fe249d900489fdceedb8e646aaaca03893aa33d4fbc8", "count": 217, "reasonCounts": { "externalAuth": 4, @@ -3752,11 +3752,11 @@ { "capabilityId": "setting.data.migration", "itemId": "scan", - "titleZh": "只读扫描本机旧版 BitFun 来源及所选数据组", - "titleEn": "Read-only scan the local legacy BitFun source and selected data groups", + "titleZh": "只读扫描本机旧版数据来源及所选数据组", + "titleEn": "Read-only scan the local legacy source and selected data groups", "reasonCode": "unstructuredInteraction", - "reasonZh": "扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", - "reasonEn": "Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user.", + "reasonZh": "“只读扫描本机旧版数据来源及所选数据组”:扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Read-only scan the local legacy source and selected data groups: Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user.", "presentationTarget": { "kind": "settings", "pageId": "data.migration" @@ -3789,8 +3789,8 @@ "titleZh": "确认关闭影响后启动独立 Data Migrator", "titleEn": "Launch the standalone Data Migrator after confirming shutdown impact", "reasonCode": "unstructuredInteraction", - "reasonZh": "启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", - "reasonEn": "Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user.", + "reasonZh": "“确认关闭影响后启动独立 Data Migrator”:启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launch the standalone Data Migrator after confirming shutdown impact: Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user.", "presentationTarget": { "kind": "settings", "pageId": "data.migration" @@ -3806,8 +3806,8 @@ "titleZh": "查看最近运行结果和各领域去敏状态", "titleEn": "Inspect the latest run result and redacted per-domain status", "reasonCode": "unstructuredInteraction", - "reasonZh": "报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", - "reasonEn": "Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user.", + "reasonZh": "“查看最近运行结果和各领域去敏状态”:报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Inspect the latest run result and redacted per-domain status: Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user.", "presentationTarget": { "kind": "settings", "pageId": "data.migration" @@ -3823,8 +3823,8 @@ "titleZh": "恢复已关闭的首次启动迁移提醒", "titleEn": "Restore the first-start migration reminder after it was disabled", "reasonCode": "unstructuredInteraction", - "reasonZh": "提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", - "reasonEn": "The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it.", + "reasonZh": "“恢复已关闭的首次启动迁移提醒”:提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "Restore the first-start migration reminder after it was disabled: The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it.", "presentationTarget": { "kind": "settings", "pageId": "data.migration" diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index ceb25a3836..c33322462f 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "cf1a4fae19862b0613cb1144cead8fcaaa7559b47d5a5f0f3a2c361be991083b", + "catalogDigest": "c77c16c414ce9fe50929fe249d900489fdceedb8e646aaaca03893aa33d4fbc8", "commandCount": 671, "coverage": { "commandCount": 671, diff --git a/scripts/dev.cjs b/scripts/dev.cjs index 9a48d5c484..494668ddfc 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -153,6 +153,7 @@ function runCommandPrefixed(prefix, cmd, args, cwd = ROOT_DIR, envOverrides = {} const child = spawn(cmd, args, { cwd, shell: process.platform === 'win32', + windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, @@ -672,6 +673,22 @@ async function main() { process.exit(1); } + // Build after version generation and before Desktop starts. Cargo checks + // freshness so an existing but stale Migrator is rebuilt as well. + if (desktopMode) { + printInfo('Preparing Data Migrator (incremental Debug build)'); + const migratorBuild = await runCommandPrefixed( + 'data-migrator', + 'cargo', + ['build', '-p', 'openbitfun-data-migrator', '--bin', 'openbitfun-data-migrator'], + ); + if (!migratorBuild.ok) { + printError('Data Migrator build failed; Desktop was not started'); + if (migratorBuild.error?.message) printError(migratorBuild.error.message); + process.exit(1); + } + } + if (desktopMode) { const baselineHelperUrl = pathToFileURL( path.join(__dirname, 'frontend-workbench-dev-baseline.mjs') diff --git a/scripts/product-identity-audit.mjs b/scripts/product-identity-audit.mjs index 35ed799fc9..84c2253364 100644 --- a/scripts/product-identity-audit.mjs +++ b/scripts/product-identity-audit.mjs @@ -21,6 +21,10 @@ const productIdentityOwner = 'src/crates/contracts/core-types/src/product_identi const retiredIdentityDataBoundaryFiles = new Set([ 'OPENBITFUN_LEGACY_DATA_MIGRATION_IMPLEMENTATION_PLAN.md', 'OPENBITFUN_LEGACY_DATA_MIGRATION_INVENTORY.md', + 'src/apps/desktop/src/api/legacy_migration_api.rs', + 'src/web-ui/src/locales/en-US/settings/legacy-migration.json', + 'src/web-ui/src/locales/zh-CN/settings/legacy-migration.json', + 'src/web-ui/src/locales/zh-TW/settings/legacy-migration.json', 'deploy/openbitfun-host/README.md', 'deploy/openbitfun-host/migrate-market-data-v1.py', 'src/apps/relay-server/README.md', diff --git a/scripts/product-identity-audit.test.mjs b/scripts/product-identity-audit.test.mjs index 5c2abf4278..6d11658599 100644 --- a/scripts/product-identity-audit.test.mjs +++ b/scripts/product-identity-audit.test.mjs @@ -105,6 +105,21 @@ test('allows only the exact legacy data-directory ignore entry', () => { }); test('limits retired identity data to the one-time production migration boundary', () => { + for (const file of [ + 'src/apps/desktop/src/api/legacy_migration_api.rs', + 'src/web-ui/src/locales/en-US/settings/legacy-migration.json', + 'src/web-ui/src/locales/zh-CN/settings/legacy-migration.json', + 'src/web-ui/src/locales/zh-TW/settings/legacy-migration.json', + ]) { + assert.deepEqual(violationsFor(retiredName, file), []); + } + for (const file of [ + 'src/apps/desktop/src/lib.rs', + 'src/web-ui/src/locales/en-US/settings.json', + 'src/shared/interactive-capabilities/catalog.json', + ]) { + assert.equal(violationsFor(retiredName, file).length, 1); + } const retiredField = ['min', 'Bit', 'fun', 'Version'].join(''); assert.deepEqual( violationsFor( diff --git a/src/apps/data-migrator/ui/app.js b/src/apps/data-migrator/ui/app.js index 3d230ea1e9..df1ca5b99b 100644 --- a/src/apps/data-migrator/ui/app.js +++ b/src/apps/data-migrator/ui/app.js @@ -13,7 +13,7 @@ const translations = { stepConfirm: 'Step 4', planTitle: 'Confirm migration', retryWriters: 'Check processes again', start: 'Start migration', stepProgress: 'Step 5', progressTitle: 'Migration progress', phase: 'Phase', domain: 'Domain', count: 'Completed steps', - cancel: 'Cancel at a safe boundary', stepDone: 'Result', reportTitle: 'Migration report', + cancel: 'Cancel', stepDone: 'Result', reportTitle: 'Migration report', reportPrivacy: 'This summary contains counts and result codes, not credentials or user content.', exportDiagnostics: 'Export failure diagnostics', diagnosticsExported: 'Sanitized diagnostics saved to {path}', @@ -26,7 +26,7 @@ const translations = { recovery: 'A previous migration journal was found and can be resumed.', blockers: '{count} data-writing process(es) must stop before migration can continue.', noBlockers: 'No data-writing processes are blocking migration.', - steps: '{count} ordered domain step(s)', conflicts: '{count} visible conflict(s)', + steps: '{count} migration step(s)', conflicts: '{count} conflict(s)', imported: 'imported', staged: 'staged', skipped: 'skipped', warnings: 'warnings', }, 'zh-CN': { @@ -39,7 +39,7 @@ const translations = { reviewTitle: '检查扫描结果', prepare: '运行迁移预检', stepConfirm: '第 4 步', planTitle: '确认迁移', retryWriters: '重新检查进程', start: '开始迁移', stepProgress: '第 5 步', progressTitle: '迁移进度', phase: '阶段', domain: '领域', - count: '已完成步骤', cancel: '在安全边界取消', stepDone: '结果', reportTitle: '迁移报告', + count: '已完成步骤', cancel: '取消', stepDone: '结果', reportTitle: '迁移报告', reportPrivacy: '此摘要仅包含计数和结果码,不包含凭据或用户正文。', exportDiagnostics: '导出失败诊断', diagnosticsExported: '去敏诊断已保存到 {path}', openDesktop: '打开 OpenBitFun', @@ -51,7 +51,7 @@ const translations = { sourceFound: '已发现 BitFun {version}。迁移期间来源保持只读。', recovery: '发现上次迁移日志,可以从安全状态继续。', blockers: '迁移前还需停止 {count} 个数据写入进程。', noBlockers: '没有进程阻止迁移。', - steps: '{count} 个有序领域步骤', conflicts: '{count} 个可见冲突', + steps: '{count} 个迁移步骤', conflicts: '{count} 个冲突', imported: '已导入', staged: '已暂存', skipped: '已跳过', warnings: '警告', }, 'zh-TW': { @@ -64,7 +64,7 @@ const translations = { reviewTitle: '檢查掃描結果', prepare: '執行遷移預檢', stepConfirm: '第 4 步', planTitle: '確認遷移', retryWriters: '重新檢查程序', start: '開始遷移', stepProgress: '第 5 步', progressTitle: '遷移進度', phase: '階段', domain: '領域', - count: '已完成步驟', cancel: '在安全邊界取消', stepDone: '結果', reportTitle: '遷移報告', + count: '已完成步驟', cancel: '取消', stepDone: '結果', reportTitle: '遷移報告', reportPrivacy: '此摘要僅包含計數和結果碼,不包含憑據或使用者正文。', exportDiagnostics: '匯出失敗診斷', diagnosticsExported: '去敏診斷已儲存至 {path}', openDesktop: '開啟 OpenBitFun', @@ -76,7 +76,7 @@ const translations = { sourceFound: '已發現 BitFun {version}。遷移期間來源保持唯讀。', recovery: '發現上次遷移日誌,可以從安全狀態繼續。', blockers: '遷移前還需停止 {count} 個資料寫入程序。', noBlockers: '沒有程序阻止遷移。', - steps: '{count} 個有序領域步驟', conflicts: '{count} 個可見衝突', + steps: '{count} 個遷移步驟', conflicts: '{count} 個衝突', imported: '已匯入', staged: '已暫存', skipped: '已略過', warnings: '警告', }, }; @@ -97,24 +97,24 @@ const groups = [ 'zh-TW': ['設定與服務憑據', '匯入設定;無法解密的憑據會標記為需要重新登入。'], }], ['agents_skills_and_miniapps', { - en: ['Agents, Skills, and MiniApps', 'User content is imported; system Skills and built-in MiniApps are excluded.'], - 'zh-CN': ['Agents、Skills 与 MiniApps', '导入用户内容;排除系统 Skills 和内置 MiniApps。'], - 'zh-TW': ['Agents、Skills 與 MiniApps', '匯入使用者內容;排除系統 Skills 和內建 MiniApps。'], + en: ['Agents, Skills, and MiniApps', 'Imports user extensions and saved data from built-in MiniApps. Built-in code is provided by OpenBitFun.'], + 'zh-CN': ['Agents、Skills 与 MiniApps', '导入用户扩展和内置 MiniApps 的使用数据;内置代码由新版提供。'], + 'zh-TW': ['Agents、Skills 與 MiniApps', '匯入使用者擴充與內建 MiniApps 的使用資料;內建程式碼由新版提供。'], }], ['workspaces_sessions_and_tasks', { - en: ['Workspaces, sessions, and tasks', 'Includes coordination.sqlite as a required atomic dependency.'], - 'zh-CN': ['工作区、会话与 Agent 任务状态', '强制包含 coordination.sqlite 作为原子依赖。'], - 'zh-TW': ['工作區、工作階段與 Agent 任務狀態', '強制包含 coordination.sqlite 作為原子相依項。'], + en: ['Workspaces, sessions, and tasks', 'Imports workspaces, conversation history, and Agent task status.'], + 'zh-CN': ['工作区、会话与 Agent 任务状态', '导入工作区、会话历史与 Agent 任务状态。'], + 'zh-TW': ['工作區、工作階段與 Agent 任務狀態', '匯入工作區、會話歷史與 Agent 任務狀態。'], }], ['memory', { - en: ['Memory', 'Imports structured and file-backed memory without user-content telemetry.'], - 'zh-CN': ['记忆', '导入结构化与文件记忆,不发送用户正文遥测。'], - 'zh-TW': ['記憶', '匯入結構化與檔案記憶,不傳送使用者正文遙測。'], + en: ['Memory', 'Imports memory databases and memory files.'], + 'zh-CN': ['记忆', '导入记忆数据库与记忆文件。'], + 'zh-TW': ['記憶', '匯入記憶資料庫與記憶檔案。'], }], ['remote_connections_and_devices', { - en: ['Remote connections and devices', 'Non-portable identities and secrets are marked for repair or sign-in.'], - 'zh-CN': ['远程连接与设备', '不可移植的身份和秘密会标记为需要修复或重新登录。'], - 'zh-TW': ['遠端連線與裝置', '不可移植的身分和秘密會標記為需要修復或重新登入。'], + en: ['Remote connections and devices', 'Imports remote connections and device settings. Some connections may require signing in again.'], + 'zh-CN': ['远程连接与设备', '导入远程连接与设备设置;部分连接可能需要重新登录。'], + 'zh-TW': ['遠端連線與裝置', '匯入遠端連線與裝置設定;部分連線可能需要重新登入。'], }], ]; @@ -237,7 +237,7 @@ function render(view) { const reportSummary = document.getElementById('report-summary'); if (view.report) { reportSummary.replaceChildren(...view.report.domainResults.map((result) => - row(result.domain, `${result.imported} ${transferLabel(result)}, ${result.skipped} ${text.skipped}, ${result.warnings.length} ${text.warnings}`))); + row(result.domain, `${result.imported} ${transferLabel(result)}, ${result.skipped} ${text.skipped}, ${result.warnings.filter((item) => item.severity !== 'info').length} ${text.warnings}`))); } show('dev-restart-help', !view.restartDesktopOnFinish); document.getElementById('open-desktop').textContent = view.restartDesktopOnFinish diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 9d0955f8af..9a5b67df6e 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -567,7 +567,7 @@ pub async fn run() { Ok(api::legacy_migration_api::StartupProbeDisposition::MigratorLaunched) => return, Err(error) => { show_fatal_startup_error(&format!( - "OpenBitFun could not safely inspect legacy BitFun migration state and cannot continue.\n\n{}\n\nRepair the installation or the migration state before retrying.", + "OpenBitFun could not safely inspect legacy migration state and cannot continue.\n\n{}\n\nRepair the installation or the migration state before retrying.", api::legacy_migration_api::LegacyMigrationCommandError::from(error).message )); return; diff --git a/src/crates/assembly/core/src/legacy_migration/agent_coordination.rs b/src/crates/assembly/core/src/legacy_migration/agent_coordination.rs index 4912442f27..14da87d8d0 100644 --- a/src/crates/assembly/core/src/legacy_migration/agent_coordination.rs +++ b/src/crates/assembly/core/src/legacy_migration/agent_coordination.rs @@ -1652,7 +1652,7 @@ mod tests { let report = engine .execute(&plan, &CancellationToken::default(), &NoCrashInjection) .unwrap(); - assert_eq!(report.status, MigrationRunStatus::CompletedWithWarnings); + assert_eq!(report.status, MigrationRunStatus::Completed); assert!(report .domain_results .iter() @@ -1787,7 +1787,7 @@ mod tests { .execute(&plan, &CancellationToken::default(), &NoCrashInjection) .unwrap(); - assert_eq!(report.status, MigrationRunStatus::CompletedWithWarnings); + assert_eq!(report.status, MigrationRunStatus::Completed); let sessions_result = report .domain_results .iter() @@ -1797,7 +1797,8 @@ mod tests { assert!(sessions_result .warnings .iter() - .any(|warning| warning.code == "session_parent_not_present")); + .any(|warning| warning.code == "session_parent_not_present" + && warning.severity == FindingSeverity::Info)); let sessions_root = roots .target_home_root @@ -2086,6 +2087,14 @@ mod tests { copy_directory(&fixture.join("user-root"), &roots.legacy_user_root).unwrap(); copy_directory(&fixture.join("home"), &roots.legacy_home_root).unwrap(); copy_directory(&fixture.join("ssh"), &roots.legacy_ssh_root).unwrap(); + // The archived fixture uses a Windows path; identity validation needs + // an absolute path in the platform running this test. + let workspace_path = roots.legacy_user_root.join("data/workspace_data.json"); + let mut workspace_data: serde_json::Value = + serde_json::from_slice(&fs::read(&workspace_path).unwrap()).unwrap(); + workspace_data["workspaces"]["workspace-1"]["rootPath"] = + serde_json::json!(roots.legacy_home_root.join("fixture-workspace")); + atomic_write_json(&workspace_path, &workspace_data).unwrap(); } fn materialize_coordination(roots: &MigrationRoots, with_wal_row: bool) -> Connection { @@ -2177,7 +2186,7 @@ mod tests { fn test_tempdir(label: &str) -> tempfile::TempDir { let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("E:/tmp")); + .unwrap_or_else(std::env::temp_dir); fs::create_dir_all(&root).unwrap(); tempfile::Builder::new() .prefix(&format!("openbitfun-migration-{label}-")) diff --git a/src/crates/assembly/core/src/legacy_migration/extensions.rs b/src/crates/assembly/core/src/legacy_migration/extensions.rs index 445eadde2e..05a5e27f8e 100644 --- a/src/crates/assembly/core/src/legacy_migration/extensions.rs +++ b/src/crates/assembly/core/src/legacy_migration/extensions.rs @@ -916,9 +916,7 @@ fn conflict_for_action( "extension_duplicate_skipped", ConflictResolution::DuplicateSkipped, ), - ImportAction::BuiltinStorage => { - ("builtin_code_excluded", ConflictResolution::SourceImported) - } + ImportAction::BuiltinStorage => return None, ImportAction::TargetWins => ( "builtin_storage_target_preserved", ConflictResolution::TargetWins, @@ -963,7 +961,12 @@ fn result_from_manifest( let conflicts = manifest .entries .iter() - .filter(|entry| entry.action != ImportAction::Import) + .filter(|entry| { + !matches!( + entry.action, + ImportAction::Import | ImportAction::BuiltinStorage + ) + }) .count() as u64; let warnings = manifest .skipped_paths @@ -1811,7 +1814,7 @@ mod tests { fn test_tempdir(label: &str) -> tempfile::TempDir { let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("E:/tmp")); + .unwrap_or_else(std::env::temp_dir); fs::create_dir_all(&root).unwrap(); tempfile::Builder::new() .prefix(&format!("openbitfun-migration-{label}-")) diff --git a/src/crates/assembly/core/src/legacy_migration/memory.rs b/src/crates/assembly/core/src/legacy_migration/memory.rs index c5e7c3dba6..b740ebd1d2 100644 --- a/src/crates/assembly/core/src/legacy_migration/memory.rs +++ b/src/crates/assembly/core/src/legacy_migration/memory.rs @@ -2114,7 +2114,7 @@ mod tests { fn test_tempdir(label: &str) -> tempfile::TempDir { let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("E:/tmp")); + .unwrap_or_else(std::env::temp_dir); fs::create_dir_all(&root).unwrap(); tempfile::Builder::new() .prefix(&format!("obfm-{}-", &label[..label.len().min(4)])) diff --git a/src/crates/assembly/core/src/legacy_migration/settings.rs b/src/crates/assembly/core/src/legacy_migration/settings.rs index f85223a0ae..071c17fe91 100644 --- a/src/crates/assembly/core/src/legacy_migration/settings.rs +++ b/src/crates/assembly/core/src/legacy_migration/settings.rs @@ -1208,7 +1208,7 @@ mod tests { fn test_tempdir(label: &str) -> tempfile::TempDir { let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("E:/tmp")); + .unwrap_or_else(std::env::temp_dir); fs::create_dir_all(&root).unwrap(); tempfile::Builder::new() .prefix(&format!("openbitfun-migration-{label}-")) diff --git a/src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs b/src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs index d97b2773d5..bdd3dc4dc6 100644 --- a/src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs +++ b/src/crates/assembly/core/src/legacy_migration/workspace_sessions.rs @@ -308,19 +308,19 @@ impl LegacyDomainAdapter for WorkspaceSessionsAdapter { .iter() .map(|path| MigrationDiagnostic { code: "session_path_not_migrated".to_string(), - severity: FindingSeverity::Warning, + severity: FindingSeverity::Info, domain: Some(self.domain()), relative_path: Some(path.clone()), message: "A non-owned or rebuildable Session path was left in the legacy source" .to_string(), - action: Some("Review the migration report before removing legacy data".to_string()), + action: None, }) .collect::>(); let orphaned_relationships = orphaned_session_relationship_count(&manifest); if orphaned_relationships > 0 { warnings.push(MigrationDiagnostic { code: "session_parent_not_present".to_string(), - severity: FindingSeverity::Warning, + severity: FindingSeverity::Info, domain: Some(self.domain()), relative_path: None, message: format!( @@ -1951,6 +1951,9 @@ mod tests { let workspace_path = source_workspace_data_path(&roots); let mut workspace_data: serde_json::Value = serde_json::from_slice(&fs::read(&workspace_path).unwrap()).unwrap(); + // Use a native absolute path instead of the archived Windows path. + workspace_data["workspaces"]["workspace-1"]["rootPath"] = + serde_json::json!(roots.legacy_home_root.join("fixture-workspace")); workspace_data["workspaces"]["assistant-legacy"] = serde_json::json!({ "id": "assistant-legacy", "name": "Personal assistant", @@ -2123,7 +2126,7 @@ mod tests { fn test_tempdir(label: &str) -> tempfile::TempDir { let root = std::env::var_os("OPENBITFUN_TEST_TMPDIR") .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("E:/tmp")); + .unwrap_or_else(std::env::temp_dir); fs::create_dir_all(&root).unwrap(); tempfile::Builder::new() .prefix(&format!("openbitfun-migration-{label}-")) diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index c53ddd9c50..dd663557c6 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "cf1a4fae19862b0613cb1144cead8fcaaa7559b47d5a5f0f3a2c361be991083b", + "digest": "c77c16c414ce9fe50929fe249d900489fdceedb8e646aaaca03893aa33d4fbc8", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -30240,42 +30240,42 @@ "categoryId": "data", "titleZh": "旧版数据迁移", "titleEn": "Legacy data migration", - "summaryZh": "从本机旧版 BitFun 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", - "summaryEn": "Scan and import supported data from a local legacy BitFun installation, inspect redacted reports, and leave the legacy source unchanged.", + "summaryZh": "从本机旧版 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", + "summaryEn": "Scan and import supported data from a local legacy installation, inspect redacted reports, and leave the legacy source unchanged.", "keywordsZh": [ "旧版数据迁移", - "BitFun 数据", + "旧版数据", "迁移报告", "导入旧数据", "Data Migrator" ], "keywordsEn": [ "legacy data migration", - "BitFun data", + "legacy data", "migration report", "import old data", "Data Migrator" ], "highlightsZh": [ - "只读扫描本机旧版 BitFun 数据", + "只读扫描本机旧版数据", "按五个高层数据组选择迁移范围", "通过独立 Data Migrator 导入并查看去敏报告" ], "highlightsEn": [ - "Read-only scan of local legacy BitFun data", + "Read-only scan of local legacy data", "Choose migration scope across five high-level data groups", "Import through the standalone Data Migrator and inspect redacted reports" ], "items": [ { "id": "scan", - "titleZh": "只读扫描本机旧版 BitFun 来源及所选数据组", - "titleEn": "Read-only scan the local legacy BitFun source and selected data groups", + "titleZh": "只读扫描本机旧版数据来源及所选数据组", + "titleEn": "Read-only scan the local legacy source and selected data groups", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", - "reasonEn": "Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." + "reasonZh": "“只读扫描本机旧版数据来源及所选数据组”:扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Read-only scan the local legacy source and selected data groups: Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." } }, { @@ -30296,8 +30296,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", - "reasonEn": "Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." + "reasonZh": "“确认关闭影响后启动独立 Data Migrator”:启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launch the standalone Data Migrator after confirming shutdown impact: Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." } }, { @@ -30307,8 +30307,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", - "reasonEn": "Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." + "reasonZh": "“查看最近运行结果和各领域去敏状态”:报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Inspect the latest run result and redacted per-domain status: Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." } }, { @@ -30318,8 +30318,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", - "reasonEn": "The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." + "reasonZh": "“恢复已关闭的首次启动迁移提醒”:提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "Restore the first-start migration reminder after it was disabled: The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." } } ], @@ -30335,11 +30335,11 @@ ], "agentExamplesZh": [ "打开旧版数据迁移", - "带我查看 BitFun 迁移报告" + "带我查看数据迁移报告" ], "agentExamplesEn": [ "Open legacy data migration", - "Show me the BitFun migration report" + "Show me the data migration report" ], "destination": { "kind": "settings", @@ -30353,22 +30353,22 @@ "Legacy data migration", "数据与诊断", "Data & diagnostics", - "BitFun 数据", + "旧版数据", "迁移报告", "导入旧数据", "Data Migrator", "legacy data migration", - "BitFun data", + "legacy data", "migration report", "import old data", - "只读扫描本机旧版 BitFun 数据", + "只读扫描本机旧版数据", "按五个高层数据组选择迁移范围", "通过独立 Data Migrator 导入并查看去敏报告", - "Read-only scan of local legacy BitFun data", + "Read-only scan of local legacy data", "Choose migration scope across five high-level data groups", "Import through the standalone Data Migrator and inspect redacted reports", - "只读扫描本机旧版 BitFun 来源及所选数据组", - "Read-only scan the local legacy BitFun source and selected data groups", + "只读扫描本机旧版数据来源及所选数据组", + "Read-only scan the local legacy source and selected data groups", "选择设置、扩展、会话、记忆和远程连接迁移范围", "Choose settings, extensions, sessions, memory, and remote-connection migration scope", "确认关闭影响后启动独立 Data Migrator", @@ -30378,9 +30378,9 @@ "恢复已关闭的首次启动迁移提醒", "Restore the first-start migration reminder after it was disabled", "打开旧版数据迁移", - "带我查看 BitFun 迁移报告", + "带我查看数据迁移报告", "Open legacy data migration", - "Show me the BitFun migration report" + "Show me the data migration report" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.migration/" }, diff --git a/src/crates/services/legacy-migration/src/engine.rs b/src/crates/services/legacy-migration/src/engine.rs index 990bb455e8..81d5a61ff9 100644 --- a/src/crates/services/legacy-migration/src/engine.rs +++ b/src/crates/services/legacy-migration/src/engine.rs @@ -621,10 +621,14 @@ impl MigrationEngine { diagnostic.severity, FindingSeverity::Warning | FindingSeverity::Blocking ) - }) || report - .domain_results - .iter() - .any(|result| !result.warnings.is_empty()); + }) || report.domain_results.iter().any(|result| { + result.warnings.iter().any(|diagnostic| { + matches!( + diagnostic.severity, + FindingSeverity::Warning | FindingSeverity::Blocking + ) + }) + }); let final_status = if has_warnings { MigrationRunStatus::CompletedWithWarnings } else { diff --git a/src/crates/services/legacy-migration/src/handoff.rs b/src/crates/services/legacy-migration/src/handoff.rs index a9a4230530..b717d67f64 100644 --- a/src/crates/services/legacy-migration/src/handoff.rs +++ b/src/crates/services/legacy-migration/src/handoff.rs @@ -817,23 +817,15 @@ pub fn launch_trusted_executable( #[cfg(windows)] fn allow_inherited_job_dev_retry(error: &std::io::Error) -> bool { - should_retry_without_job_breakaway( - cfg!(debug_assertions), - std::env::var_os("OPENBITFUN_ALLOW_UNSIGNED_MIGRATOR_DEV").as_deref() - == Some(OsStr::new("1")), - error.kind(), - ) + should_retry_without_job_breakaway(cfg!(debug_assertions), error.kind()) } #[cfg(any(windows, test))] fn should_retry_without_job_breakaway( debug_assertions: bool, - unsigned_migrator_dev_enabled: bool, error_kind: std::io::ErrorKind, ) -> bool { - debug_assertions - && unsigned_migrator_dev_enabled - && error_kind == std::io::ErrorKind::PermissionDenied + debug_assertions && error_kind == std::io::ErrorKind::PermissionDenied } fn validate_binary_name(name: &str) -> LegacyMigrationResult<()> { @@ -990,26 +982,18 @@ mod tests { } #[test] - fn inherited_job_retry_requires_debug_opt_in_and_permission_denied() { + fn inherited_job_retry_requires_debug_and_permission_denied() { use std::io::ErrorKind; assert!(should_retry_without_job_breakaway( - true, true, ErrorKind::PermissionDenied )); assert!(!should_retry_without_job_breakaway( false, - true, ErrorKind::PermissionDenied )); assert!(!should_retry_without_job_breakaway( - true, - false, - ErrorKind::PermissionDenied - )); - assert!(!should_retry_without_job_breakaway( - true, true, ErrorKind::NotFound )); diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index dc31cd938d..5ec7b41f3d 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -9131,42 +9131,42 @@ "categoryId": "data", "titleZh": "旧版数据迁移", "titleEn": "Legacy data migration", - "summaryZh": "从本机旧版 BitFun 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", - "summaryEn": "Scan and import supported data from a local legacy BitFun installation, inspect redacted reports, and leave the legacy source unchanged.", + "summaryZh": "从本机旧版 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", + "summaryEn": "Scan and import supported data from a local legacy installation, inspect redacted reports, and leave the legacy source unchanged.", "keywordsZh": [ "旧版数据迁移", - "BitFun 数据", + "旧版数据", "迁移报告", "导入旧数据", "Data Migrator" ], "keywordsEn": [ "legacy data migration", - "BitFun data", + "legacy data", "migration report", "import old data", "Data Migrator" ], "highlightsZh": [ - "只读扫描本机旧版 BitFun 数据", + "只读扫描本机旧版数据", "按五个高层数据组选择迁移范围", "通过独立 Data Migrator 导入并查看去敏报告" ], "highlightsEn": [ - "Read-only scan of local legacy BitFun data", + "Read-only scan of local legacy data", "Choose migration scope across five high-level data groups", "Import through the standalone Data Migrator and inspect redacted reports" ], "items": [ { "id": "scan", - "titleZh": "只读扫描本机旧版 BitFun 来源及所选数据组", - "titleEn": "Read-only scan the local legacy BitFun source and selected data groups", + "titleZh": "只读扫描本机旧版数据来源及所选数据组", + "titleEn": "Read-only scan the local legacy source and selected data groups", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", - "reasonEn": "Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." + "reasonZh": "“只读扫描本机旧版数据来源及所选数据组”:扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Read-only scan the local legacy source and selected data groups: Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." }, "evidence": [ "command:get_legacy_migration_status", @@ -9195,8 +9195,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", - "reasonEn": "Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." + "reasonZh": "“确认关闭影响后启动独立 Data Migrator”:启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launch the standalone Data Migrator after confirming shutdown impact: Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." }, "evidence": [ "command:prepare_legacy_migration", @@ -9210,8 +9210,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", - "reasonEn": "Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." + "reasonZh": "“查看最近运行结果和各领域去敏状态”:报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Inspect the latest run result and redacted per-domain status: Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." }, "evidence": [ "command:get_legacy_migration_report", @@ -9225,8 +9225,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", - "reasonEn": "The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." + "reasonZh": "“恢复已关闭的首次启动迁移提醒”:提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "Restore the first-start migration reminder after it was disabled: The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." }, "evidence": [ "command:set_legacy_migration_prompt_preference", @@ -9246,11 +9246,11 @@ ], "agentExamplesZh": [ "打开旧版数据迁移", - "带我查看 BitFun 迁移报告" + "带我查看数据迁移报告" ], "agentExamplesEn": [ "Open legacy data migration", - "Show me the BitFun migration report" + "Show me the data migration report" ], "destination": { "kind": "settings", diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 403e22e76f..653efc45a6 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "cf1a4fae19862b0613cb1144cead8fcaaa7559b47d5a5f0f3a2c361be991083b", + "digest": "c77c16c414ce9fe50929fe249d900489fdceedb8e646aaaca03893aa33d4fbc8", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -29850,42 +29850,42 @@ "categoryId": "data", "titleZh": "旧版数据迁移", "titleEn": "Legacy data migration", - "summaryZh": "从本机旧版 BitFun 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", - "summaryEn": "Scan and import supported data from a local legacy BitFun installation, inspect redacted reports, and leave the legacy source unchanged.", + "summaryZh": "从本机旧版 安装扫描并导入受支持的数据,查看去敏报告,同时保持旧来源不变。", + "summaryEn": "Scan and import supported data from a local legacy installation, inspect redacted reports, and leave the legacy source unchanged.", "keywordsZh": [ "旧版数据迁移", - "BitFun 数据", + "旧版数据", "迁移报告", "导入旧数据", "Data Migrator" ], "keywordsEn": [ "legacy data migration", - "BitFun data", + "legacy data", "migration report", "import old data", "Data Migrator" ], "highlightsZh": [ - "只读扫描本机旧版 BitFun 数据", + "只读扫描本机旧版数据", "按五个高层数据组选择迁移范围", "通过独立 Data Migrator 导入并查看去敏报告" ], "highlightsEn": [ - "Read-only scan of local legacy BitFun data", + "Read-only scan of local legacy data", "Choose migration scope across five high-level data groups", "Import through the standalone Data Migrator and inspect redacted reports" ], "items": [ { "id": "scan", - "titleZh": "只读扫描本机旧版 BitFun 来源及所选数据组", - "titleEn": "Read-only scan the local legacy BitFun source and selected data groups", + "titleZh": "只读扫描本机旧版数据来源及所选数据组", + "titleEn": "Read-only scan the local legacy source and selected data groups", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", - "reasonEn": "Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." + "reasonZh": "“只读扫描本机旧版数据来源及所选数据组”:扫描依赖当前设备上的旧版数据、实时范围选择和结果状态;Agent 会打开精确入口,并把选择与扫描保留在用户可见界面。", + "reasonEn": "Read-only scan the local legacy source and selected data groups: Scanning depends on legacy data on the current device, live scope selection, and result state; the Agent opens the exact entry and keeps selection and scanning visible to the user." } }, { @@ -29906,8 +29906,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", - "reasonEn": "Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." + "reasonZh": "“确认关闭影响后启动独立 Data Migrator”:启动迁移器会停止正在运行的 Agent 和终端任务、关闭 Desktop,并交接到独立本机进程;Agent 只打开入口,确认和启动保留在用户可见界面。", + "reasonEn": "Launch the standalone Data Migrator after confirming shutdown impact: Launching the migrator can stop running agents and terminal tasks, close Desktop, and hand off to a separate local process; the Agent only opens the entry while confirmation and launch remain visible to the user." } }, { @@ -29917,8 +29917,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", - "reasonEn": "Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." + "reasonZh": "“查看最近运行结果和各领域去敏状态”:报告取决于本机最近一次迁移运行和各领域实时状态;Agent 会打开精确入口,并把报告查看与失败组重试保留在用户可见界面。", + "reasonEn": "Inspect the latest run result and redacted per-domain status: Reports depend on the most recent local migration run and live per-domain state; the Agent opens the exact entry and keeps report review and failed-group retry visible to the user." } }, { @@ -29928,8 +29928,8 @@ "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", - "reasonEn": "The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." + "reasonZh": "“恢复已关闭的首次启动迁移提醒”:提醒偏好与当前本机旧数据来源绑定;Agent 会打开精确入口,由用户在可见界面决定是否恢复提醒。", + "reasonEn": "Restore the first-start migration reminder after it was disabled: The reminder preference is bound to the current local legacy source; the Agent opens the exact entry so the user can decide visibly whether to restore it." } } ], @@ -29945,11 +29945,11 @@ ], "agentExamplesZh": [ "打开旧版数据迁移", - "带我查看 BitFun 迁移报告" + "带我查看数据迁移报告" ], "agentExamplesEn": [ "Open legacy data migration", - "Show me the BitFun migration report" + "Show me the data migration report" ], "destination": { "kind": "settings", @@ -29963,22 +29963,22 @@ "Legacy data migration", "数据与诊断", "Data & diagnostics", - "BitFun 数据", + "旧版数据", "迁移报告", "导入旧数据", "Data Migrator", "legacy data migration", - "BitFun data", + "legacy data", "migration report", "import old data", - "只读扫描本机旧版 BitFun 数据", + "只读扫描本机旧版数据", "按五个高层数据组选择迁移范围", "通过独立 Data Migrator 导入并查看去敏报告", - "Read-only scan of local legacy BitFun data", + "Read-only scan of local legacy data", "Choose migration scope across five high-level data groups", "Import through the standalone Data Migrator and inspect redacted reports", - "只读扫描本机旧版 BitFun 来源及所选数据组", - "Read-only scan the local legacy BitFun source and selected data groups", + "只读扫描本机旧版数据来源及所选数据组", + "Read-only scan the local legacy source and selected data groups", "选择设置、扩展、会话、记忆和远程连接迁移范围", "Choose settings, extensions, sessions, memory, and remote-connection migration scope", "确认关闭影响后启动独立 Data Migrator", @@ -29988,9 +29988,9 @@ "恢复已关闭的首次启动迁移提醒", "Restore the first-start migration reminder after it was disabled", "打开旧版数据迁移", - "带我查看 BitFun 迁移报告", + "带我查看数据迁移报告", "Open legacy data migration", - "Show me the BitFun migration report" + "Show me the data migration report" ], "docsUrl": "https://playbook.openbitfun.com/capabilities/setting.data.migration/" }, diff --git a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts index 845398857b..64fa3d00df 100644 --- a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts @@ -382,7 +382,7 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [ categoryId: 'data', labelKey: 'navigation.pages.legacyMigration.label', descriptionKey: 'navigation.pages.legacyMigration.description', - keywords: ['legacy', 'migration', 'import', 'bitfun', 'upgrade', 'maintenance'], + keywords: ['legacy', 'migration', 'import', 'upgrade', 'maintenance'], namespaces: ['settings/legacy-migration'], searchPhrases: [ phrase('settings/legacy-migration', 'title'), diff --git a/src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts index cbc53c4fd0..c1e0589421 100644 --- a/src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts +++ b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.test.ts @@ -75,8 +75,11 @@ describe('legacy migration startup notification', () => { expect(mocks.error).toHaveBeenCalledTimes(1); expect(mocks.error.mock.calls[0][0]).toBe( - 'settings/legacy-migration:startupNotification.launchFailed', + 'startupNotification.launchFailed', ); + expect(mocks.t).toHaveBeenCalledWith('startupNotification.launchFailed', { + ns: 'settings/legacy-migration', + }); expect(mocks.error.mock.calls[0][1].metadata).toEqual({ source: 'legacy-migration-startup-error', code: 'data_migrator_launch_failed', diff --git a/src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts index 7bb3b2cb10..1044e3f69d 100644 --- a/src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts +++ b/src/web-ui/src/app/startup/legacyMigrationStartupNotification.ts @@ -31,12 +31,12 @@ export async function showLegacyMigrationStartupNotification( storage.setItem(noticeKey, 'shown'); notificationService.error( - i18nService.t(`${namespace}:startupNotification.launchFailed`), + i18nService.t('startupNotification.launchFailed', { ns: namespace }), { - title: i18nService.t(`${namespace}:startupNotification.title`), + title: i18nService.t('startupNotification.title', { ns: namespace }), duration: 0, actions: [{ - label: i18nService.t(`${namespace}:startupNotification.openSettings`), + label: i18nService.t('startupNotification.openSettings', { ns: namespace }), variant: 'primary' as const, onClick: openMigrationSettings, }], @@ -57,10 +57,10 @@ export async function showLegacyMigrationStartupNotification( storage.setItem(noticeKey, 'shown'); const options = { - title: i18nService.t(`${namespace}:startupNotification.title`), + title: i18nService.t('startupNotification.title', { ns: namespace }), duration: 0, actions: [{ - label: i18nService.t(`${namespace}:actions.viewReport`), + label: i18nService.t('actions.viewReport', { ns: namespace }), variant: 'primary' as const, onClick: openMigrationSettings, }], @@ -70,6 +70,6 @@ export async function showLegacyMigrationStartupNotification( status: report.status, }, }; - const message = i18nService.t(`${namespace}:startupNotification.statuses.${report.status}`); + const message = i18nService.t(`startupNotification.statuses.${report.status}`, { ns: namespace }); notificationService[notificationKind(report.status)](message, options); } diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index b73fa5b4ce..16c6dd80af 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "cf1a4fae19862b0613cb1144cead8fcaaa7559b47d5a5f0f3a2c361be991083b" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "c77c16c414ce9fe50929fe249d900489fdceedb8e646aaaca03893aa33d4fbc8" as const; export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.migration" | "setting.data.diagnostics"; diff --git a/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.tsx b/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.tsx index a4640e908d..7f83e9b6cd 100644 --- a/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.tsx +++ b/src/web-ui/src/infrastructure/config/components/LegacyMigrationSettingsPage.tsx @@ -1,4 +1,4 @@ -import { Button, Checkbox, StatusPill, type StatusPillTone } from '@openbitfun/ui'; +import { Button, Checkbox, Dialog, DialogBody, DialogClose, DialogHeader, DialogHeading, DialogTitle, StatusPill, type StatusPillTone } from '@openbitfun/ui'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { confirmDialog } from '@/infrastructure/confirm-dialog'; import { useI18n } from '@/infrastructure/i18n'; @@ -75,6 +75,7 @@ export default function LegacyMigrationSettingsPage() { const [status, setStatus] = useState(null); const [scan, setScan] = useState(null); const [report, setReport] = useState(null); + const [reportOpen, setReportOpen] = useState(false); const [selectedGroups, setSelectedGroups] = useState>( () => new Set(MIGRATION_GROUPS), ); @@ -176,6 +177,7 @@ export default function LegacyMigrationSettingsPage() { try { const next = await legacyMigrationAPI.getReport(); setReport(next); + setReportOpen(next !== null); if (!next) notification.info(t('messages.noReport')); } catch (error) { log.error('Failed to load legacy migration report', error); @@ -405,6 +407,37 @@ export default function LegacyMigrationSettingsPage() { )} + + + {t('actions.viewReport')} + + + + {report ? <> +

{t(`statuses.${report.status}`)}

+ {report.domainResults.map((result) => ( +
+

{t(`domains.${result.domain}`)} — {t(`domainStates.${result.state}`)}

+

{t('report.domainCounts', { + imported: formatNumber(result.imported), skipped: formatNumber(result.skipped), + conflicts: formatNumber(result.conflicts), + })}

+
    {Array.from(new Map(result.warnings.map((item) => [item.code, item])).values()).map((item) => ( +
  • + {item.code === 'session_path_not_migrated' ? t('report.excludedPaths') + : item.code === 'session_parent_not_present' ? t('report.orphanedSessions') : item.message} + {' '}{t('report.occurrences', { count: result.warnings.filter((entry) => entry.code === item.code).length })} + {item.action ?

    {item.action}

    : null} +
  • + ))}
+
+ ))} + {report.diagnostics.map((item, index) =>

{item.message}

)} + {(report.requiresReauthentication ?? []).length > 0 ?

{t('report.reauthentication')}

: null} + {(report.requiresRelocation ?? []).length > 0 ?

{t('report.relocation')}

: null} + : null} +
+
); } diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json index 681833b4fa..424eb21ae1 100644 --- a/src/web-ui/src/locales/en-US/settings.json +++ b/src/web-ui/src/locales/en-US/settings.json @@ -31,7 +31,7 @@ "acp": { "label": "ACP Agents", "description": "Configure external agents that connect through Agent Client Protocol." }, "usage": { "label": "Call Statistics", "description": "Model requests, token usage, and cache hit rates." }, "archivedSessions": { "label": "Archived Sessions", "description": "View, restore, or permanently delete archived sessions." }, - "legacyMigration": { "label": "Data Migration", "description": "Import supported data from an older BitFun installation." }, + "legacyMigration": { "label": "Data Migration", "description": "Import supported data from an older installation." }, "diagnostics": { "label": "Logs & Diagnostics", "description": "Log levels, runtime information, and troubleshooting." } }, "views": { diff --git a/src/web-ui/src/locales/en-US/settings/legacy-migration.json b/src/web-ui/src/locales/en-US/settings/legacy-migration.json index 8b4765a7c7..05255bea08 100644 --- a/src/web-ui/src/locales/en-US/settings/legacy-migration.json +++ b/src/web-ui/src/locales/en-US/settings/legacy-migration.json @@ -60,6 +60,11 @@ "blocked": "Needs attention" }, "report": { + "excludedPaths": "Non-core session files were left in the legacy source as intended. No action is required.", + "orphanedSessions": "Some parent sessions are absent from the legacy source. Relationships and session history were preserved; no action is required.", + "occurrences": "{{count}} records", + "reauthentication": "Some connections require authentication again. Review connection settings.", + "relocation": "Some paths require relocation. Review workspace settings.", "result": "Run result", "none": "No migration report is available yet.", "timeUnavailable": "Completion time unavailable", diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json index eb67be05bd..ec361d80b6 100644 --- a/src/web-ui/src/locales/zh-CN/settings.json +++ b/src/web-ui/src/locales/zh-CN/settings.json @@ -31,7 +31,7 @@ "acp": { "label": "ACP Agent", "description": "配置通过 Agent Client Protocol 接入的外部 Agent。" }, "usage": { "label": "调用统计", "description": "模型请求数、Token 用量与缓存命中率。" }, "archivedSessions": { "label": "已归档会话", "description": "查看、恢复或永久删除已归档的会话。" }, - "legacyMigration": { "label": "数据迁移", "description": "从受支持的旧版 BitFun 安装导入数据。" }, + "legacyMigration": { "label": "数据迁移", "description": "从受支持的旧版 安装导入数据。" }, "diagnostics": { "label": "日志与诊断", "description": "日志级别、运行信息与故障排查。" } }, "views": { diff --git a/src/web-ui/src/locales/zh-CN/settings/legacy-migration.json b/src/web-ui/src/locales/zh-CN/settings/legacy-migration.json index d89e55cf8b..8d6feb9951 100644 --- a/src/web-ui/src/locales/zh-CN/settings/legacy-migration.json +++ b/src/web-ui/src/locales/zh-CN/settings/legacy-migration.json @@ -48,6 +48,11 @@ "blocked": "需要处理" }, "report": { + "excludedPaths": "按迁移范围保留在旧数据中的非核心会话文件,无需处理。", + "orphanedSessions": "部分会话的父会话在旧数据中不存在;已保留关系及会话历史,无需处理。", + "occurrences": "共 {{count}} 条记录", + "reauthentication": "部分连接需要重新认证,请到连接设置中处理。", + "relocation": "部分路径需要重新定位,请检查工作区设置。", "result": "运行结果", "none": "尚无迁移报告。", "timeUnavailable": "完成时间不可用", diff --git a/src/web-ui/src/locales/zh-TW/settings.json b/src/web-ui/src/locales/zh-TW/settings.json index 63fd98ab61..d420864e33 100644 --- a/src/web-ui/src/locales/zh-TW/settings.json +++ b/src/web-ui/src/locales/zh-TW/settings.json @@ -31,7 +31,7 @@ "acp": { "label": "ACP Agent", "description": "設定透過 Agent Client Protocol 接入的外部 Agent。" }, "usage": { "label": "調用統計", "description": "模型請求數、Token 用量與快取命中率。" }, "archivedSessions": { "label": "已歸檔會話", "description": "檢視、恢復或永久刪除已歸檔的會話。" }, - "legacyMigration": { "label": "資料遷移", "description": "從受支援的舊版 BitFun 安裝匯入資料。" }, + "legacyMigration": { "label": "資料遷移", "description": "從受支援的舊版 產品安裝匯入資料。" }, "diagnostics": { "label": "日誌與診斷", "description": "日誌層級、執行資訊與故障排查。" } }, "views": { diff --git a/src/web-ui/src/locales/zh-TW/settings/legacy-migration.json b/src/web-ui/src/locales/zh-TW/settings/legacy-migration.json index 948f5ccbd9..6d22c83153 100644 --- a/src/web-ui/src/locales/zh-TW/settings/legacy-migration.json +++ b/src/web-ui/src/locales/zh-TW/settings/legacy-migration.json @@ -48,6 +48,11 @@ "blocked": "需要處理" }, "report": { + "excludedPaths": "依遷移範圍保留在舊資料中的非核心會話檔案,無須處理。", + "orphanedSessions": "部分會話的父會話在舊資料中不存在;已保留關係及會話歷史,無須處理。", + "occurrences": "共 {{count}} 筆記錄", + "reauthentication": "部分連線需要重新認證,請至連線設定處理。", + "relocation": "部分路徑需要重新定位,請檢查工作區設定。", "result": "執行結果", "none": "尚無遷移報告。", "timeUnavailable": "完成時間不可用", From 144c898b302e49eb85fa39939182039bc10ac905 Mon Sep 17 00:00:00 2001 From: wsp Date: Tue, 8 Sep 2026 02:36:41 +0800 Subject: [PATCH 7/7] test(process): Stabilize Windows tree fixtures Windows CI timed out waiting for descendant PIDs in both process-tree cleanup tests, although the original fixtures passed locally. - Replace PowerShell and ping fixtures with explicit parent and leaf roles in the existing test executable. - Publish readiness from the running leaf itself and keep subprocesses hidden through the shared process command helper. - Preserve parent-exit coverage, cleanup assertions, and timeout bounds without changing production process-tree behavior. This removes external shell startup and launch dependencies from the fixture without claiming a confirmed cause for the original CI timeout. Validation: process-tree tests passed ten consecutive Windows runs; all five process-runtime integration contracts also passed. --- .../services-core/src/process_tree.rs | 65 ++++++++++++++----- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/src/crates/services/services-core/src/process_tree.rs b/src/crates/services/services-core/src/process_tree.rs index 4637a9d83e..55a4d20844 100644 --- a/src/crates/services/services-core/src/process_tree.rs +++ b/src/crates/services/services-core/src/process_tree.rs @@ -473,30 +473,63 @@ mod tests { #[cfg(windows)] fn descendant_fixture(pid_file: &Path) -> Command { - let script = r#"$child = Start-Process -FilePath "$env:SystemRoot\System32\ping.exe" -ArgumentList '-t','127.0.0.1' -WindowStyle Hidden -PassThru; [IO.File]::WriteAllText($env:OPENBITFUN_DESCENDANT_PID_FILE, [string]$child.Id); while ($true) { Start-Sleep -Seconds 60 }"#; - let mut command = Command::new("powershell.exe"); - command - .arg("-NoProfile") - .arg("-NonInteractive") - .arg("-Command") - .arg(script) - .env("OPENBITFUN_DESCENDANT_PID_FILE", pid_file); - command + Command::from(windows_fixture_command(pid_file, "parent")) } #[cfg(windows)] fn orphaned_descendant_fixture(pid_file: &Path) -> Command { - let script = r#"$child = Start-Process -FilePath "$env:SystemRoot\System32\ping.exe" -ArgumentList '-t','127.0.0.1' -WindowStyle Hidden -PassThru; [IO.File]::WriteAllText($env:OPENBITFUN_DESCENDANT_PID_FILE, [string]$child.Id)"#; - let mut command = Command::new("powershell.exe"); + Command::from(windows_fixture_command(pid_file, "orphan-parent")) + } + + #[cfg(windows)] + fn windows_fixture_command(pid_file: &Path, role: &str) -> std::process::Command { + // Reuse the test binary so readiness does not depend on PowerShell + // startup, Start-Process behavior, or an external ping executable. + let mut command = crate::process_manager::create_command( + std::env::current_exe().expect("locate process-tree test executable"), + ); command - .arg("-NoProfile") - .arg("-NonInteractive") - .arg("-Command") - .arg(script) - .env("OPENBITFUN_DESCENDANT_PID_FILE", pid_file); + .args([ + "--exact", + "process_tree::tests::windows_fixture_process", + "--nocapture", + ]) + .env("OPENBITFUN_PROCESS_TREE_FIXTURE_ROLE", role) + .env("OPENBITFUN_DESCENDANT_PID_FILE", pid_file) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()); command } + #[cfg(windows)] + #[test] + fn windows_fixture_process() { + let Ok(role) = std::env::var("OPENBITFUN_PROCESS_TREE_FIXTURE_ROLE") else { + return; + }; + let pid_file = + std::env::var_os("OPENBITFUN_DESCENDANT_PID_FILE").expect("fixture PID file path"); + match role.as_str() { + "parent" | "orphan-parent" => { + let _child = windows_fixture_command(Path::new(&pid_file), "leaf") + .spawn() + .expect("spawn fixture descendant"); + if role == "orphan-parent" { + return; + } + } + "leaf" => { + std::fs::write(&pid_file, std::process::id().to_string()) + .expect("publish fixture descendant PID"); + } + _ => panic!("unknown process-tree fixture role: {role}"), + } + loop { + std::thread::sleep(Duration::from_secs(60)); + } + } + #[cfg(unix)] fn descendant_fixture(pid_file: &Path) -> Command { let mut command = Command::new("sh");