diff --git a/.agents/notes/implemented/bug-fix/2026-10-16-roost-storage-write-amplification.md b/.agents/notes/implemented/bug-fix/2026-10-16-roost-storage-write-amplification.md index 8c873d0e5a..3dced8df43 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-16-roost-storage-write-amplification.md +++ b/.agents/notes/implemented/bug-fix/2026-10-16-roost-storage-write-amplification.md @@ -125,6 +125,38 @@ consumer chain, including a committed-but-lost native reply. Its discovered rese fall-through is a renderer recovery defect; no further native write change is required by that correction. +## SQLite namespace diagnostics + +The streaming-budget test inspects a temporary Native SQLite database directly. +Its three statistics queries must support both the installed container v3 +(`roost_kv.space`) and container v4's interned namespaces (`roost_spaces.name` +joined by `space_id`). A test-local relation exposes the same `space/key/value` +columns for either format, with an explicit accepted-version assertion. Update +bytes, receipt count and projection-row budgets remain unchanged. The diagnostic +relation changes no production read/write behavior. Companion storage work: +[Roost #15](https://github.com/loro-dev/roost/pull/15). +Diagnostic compatibility PR: [#1735](https://github.com/LodyAI/Lody/pull/1735). + +The coordinated Roost 0.2.0 release is now published. PR #1735 pins the +TypeScript package in the shared catalog and Native exactly in the CLI manifest +and publication guard, retaining the published-runtime version contract. It keeps all six exact +platform dependencies in the lockfile and release-age exceptions. This upgrades +the Native runtime to SQLite container v4; earlier containers are refused, with +no migration, automatic reset or data cleanup. The dependent +[message-state fix #1737](https://github.com/LodyAI/Lody/pull/1737) consumes this +same catalog update and must merge after #1735. Acceptance is repeated against +the published packages rather than a local SDK artifact. + +Frozen installation, complete `pnpm check`, repository format and documentation +checks pass with the published 0.2.0 packages and no native override. The full CLI +suite passes 4,429 tests with four existing skips, including streaming storage +budgets and reopen. The four Electron native-staging tests pass: foreign target +selection uses synthetic bindings; the installed host binding executes real +signed SQLite write/reopen probes. The Native release manifest identifies merged +Roost source `e85c970`; this is not cross-platform runtime acceptance on this host. +The public CLI build also passes at a 2 GiB heap, including its published-bundle +runtime probes; all six existing dependency-version policy tests pass. + ## Outbox and existing data Outbox rows are not pending-delivery counts. The installed TypeScript Store's ACK diff --git a/.agents/notes/implemented/bug-fix/2026-10-16-roost-storage-write-amplification.zh.md b/.agents/notes/implemented/bug-fix/2026-10-16-roost-storage-write-amplification.zh.md index 3bd9bccb67..c2de357dee 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-16-roost-storage-write-amplification.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-10-16-roost-storage-write-amplification.zh.md @@ -103,6 +103,30 @@ Native 写入与其他客户端的增量正文读取,不足以作为发行验 把生产 RPC 加解密端点和两个渲染器副本加入同一消费者链路,并覆盖原生提交成功但回复丢失。 其中发现的 reset 快捷路径是渲染器恢复缺陷;这项更正不需要继续修改原生写入。 +## SQLite namespace 统计 + +流式预算测试直接检查临时 Native SQLite 数据库。三处统计查询同时支持当前安装的 +container v3(`roost_kv.space`)和 namespace 整数引用的 v4(通过 `space_id` +关联 `roost_spaces.name`)。测试内的查询关系为两种格式提供相同的 +`space/key/value` 列,并显式断言支持的版本;更新字节、回执行数和投影行数预算不变。 +统计查询本身不改变生产读写行为。 +配套存储改动:[Roost #15](https://github.com/loro-dev/roost/pull/15)。 +统计兼容 PR:[#1735](https://github.com/LodyAI/Lody/pull/1735)。 + +Roost 0.2.0 已统一发布。PR #1735 在共享 catalog 固定 TypeScript 包,在 CLI +清单及发布校验中精确固定 Native,沿用运行时依赖发布合同。锁文件及发布冷却期 +豁免保留全部六个精确匹配的平台包。Native 运行时 +因此升级到 SQLite container v4,拒绝更早容器,不迁移、不自动重置、不清理数据。 +依赖它的[消息状态修复 #1737](https://github.com/LodyAI/Lody/pull/1737) 复用同一次 +catalog 更新,在 #1735 后合并。验收改用 npm 正式包,不再使用本地 SDK 产物。 + +使用 npm 正式 0.2.0 包、没有 Native 覆盖时,冻结安装、完整 `pnpm check`、 +全仓格式和文档检查通过。CLI 全量 4,429 项通过、4 项原有跳过,包含流式存储预算 +及重开。Electron 四项 Native staging 测试通过:其他平台使用合成绑定验证选择, +本机安装的真实绑定执行签名 SQLite 写入及重开。Native 发布 manifest 对应已合并的 +Roost 源码 `e85c970`;这不代表在本机完成其他平台的运行时验收。 +公开 CLI 在 2 GiB 堆上限内构建通过,包含发布包运行时探针;六项已有依赖版本策略测试通过。 + ## Outbox 与既有数据 Outbox 行数不等于待发送数量。已安装 TypeScript Store 的 ACK 推进消费者游标而不删除行, diff --git a/.agents/notes/implemented/bug-fix/2026-10-20-roost-message-storage-copies.md b/.agents/notes/implemented/bug-fix/2026-10-20-roost-message-storage-copies.md new file mode 100644 index 0000000000..32aeac979e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-20-roost-message-storage-copies.md @@ -0,0 +1,84 @@ +# Roost message copies in state and batch receipts + +Status: implemented +Type: bug-fix +Translation: current +Date: 2026-10-20 + +[中文](2026-10-20-roost-message-storage-copies.zh.md) + +## Abstract + +Post-seal corrections copied an entire message into state and SDK command +receipts. State now contains operations relative to its exact sealed primary; +the execution owner uses Roost's local batch recovery journal. A synthetic +432 KiB unchanged body with 32 corrections retained 4,113 update bytes instead of +889,477, preserving the sealed primary, logical reads, legacy state reads and +idempotent recovery. The consumer now uses published Roost 0.2.0 through the +dependency update in #1735; older SQLite containers are not migrated or reset. + +## Decision and scope + +- State records contain only changed fields/text/array membership, anchored to + the primary Turn ID. Reads reconstruct the full application entry. Existing + whole-entry states remain readable within a compatible container and become deltas on their next mutation; + sealed records and retained updates are never rewritten or pruned. + This adds no SQLite-container migration. Reopening delta state requires this + adapter or newer; RPC readers still receive complete logical entries. State projection reuses the decoded + primary, and incremental state generation converts operation offsets without + serializing the accumulated text or unchanged body again. +- Local batch plans and compact pending receipts are prepared atomically by the + SDK; effects, publication, final seal and plan retirement commit atomically. + The CLI owns the full database. Turn exports synchronize committed history; + moving unfinished local work requires that database, including the journal. + The existing SDK replicated recovery default remains available to other hosts. +- This replaces part of the earlier + [write-amplification decision](2026-10-16-roost-storage-write-amplification.md), + not the guarded native streaming path, immutable human input or branch saga. +- Consume the coordinated Roost 0.2.0 release through the dependency update + in [#1735](https://github.com/LodyAI/Lody/pull/1735), then merge #1737 after it. + Native 0.2.0 requires SQLite container v4. Do not deploy, migrate/reset databases, + clean history, truncate WAL, merge PRs or publish packages in this change. + +## Acceptance and failure boundaries + +Tests verify production SQLite projections through seal, post-seal correction, later +streaming, permission overlay, close/reopen and lost native replies. Keep a large +unchanged text fixture out of every state update and batch receipt, measuring +retained update bytes rather than WAL length. Test Unicode offsets, field +removal, nested arrays and invalid base/path data. A malformed state fails its +existing history read; it never selects a different backend or retries a write. +Journal preparation/apply failures remain SDK-owned, with no Lody recovery loop. + +## Verification + +The consumer implementation is [Lody #1737](https://github.com/LodyAI/Lody/pull/1737); +the SDK implementation is [Roost #16](https://github.com/loro-dev/roost/pull/16). +The branch is rebased on [#1735](https://github.com/LodyAI/Lody/pull/1735), using +published SDK and Native 0.2.0 with exact lockfile versions and no local override. +The earlier SDK 0.1.3 `TS2554` release gate is resolved by this dependency update. + +- The two owning CLI test files pass **82 tests** on published Native SQLite v4. + Cases cover legacy whole-message state reopen/conversion, a committed first-state + reply lost before projection, continued output, permissions, branch/input + contracts, Unicode, field removal, invalid bases/paths and own JSON keys. +- Earlier paired synthetic runs compare Lody `7d6695c` with SDK 0.1.3 against this + change with the proposed SDK. Both retain **37 updates** after sealing a + 442,368-byte unchanged body and applying 32 corrections. Update value bytes fall + **889,477 → 4,113 (99.54%)**; the largest update falls **443,344 → 488**. The initial + primary, outbox copy, indexes, WAL and stream transport framing are excluded. + Both preserve old frames, primary immutability, logical content and reopen after + the lost reply. The current regression enforces bounded growth, not exact wire bytes. +- Complete `pnpm check` passes with the published packages, including all package + typechecks, ordinary typed lint, Effect lint, repository tests, i18n and boundaries. + The full CLI suite passes **4,435 tests with four existing skips**. Repository + formatting and documentation checks pass with no documentation errors. +- The public CLI builds at a **2 GiB heap limit** and passes its published-bundle + runtime/Worker probes. Desktop CLI staging succeeds; the actual Electron runtime + writes, verifies and reopens signed SQLite history through the staged Native + Worker. The four staging tests cover all six target selections with synthetic + foreign bindings and execute the installed host binding. This is not a local GUI + E2E run or execution of foreign-platform binaries; standard PR CI is rerun. + +Merge #1735 before #1737. No application database, deployment or package publication +was changed during this acceptance. diff --git a/.agents/notes/implemented/bug-fix/2026-10-20-roost-message-storage-copies.zh.md b/.agents/notes/implemented/bug-fix/2026-10-20-roost-message-storage-copies.zh.md new file mode 100644 index 0000000000..943533e37c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-20-roost-message-storage-copies.zh.md @@ -0,0 +1,34 @@ +# Roost 状态与批处理回执中的消息副本 + +Status: implemented +Type: bug-fix +Translation: current +Date: 2026-10-20 + +[English](2026-10-20-roost-message-storage-copies.md) + +## 摘要 + +封存后的消息修正此前会把整条消息复制进状态和 SDK 命令回执。现在状态只记录相对指定已封存 primary 的操作,执行机器使用 Roost 的本地批处理恢复日志。含 432 KiB 不变正文、修正 32 次的合成用例,其保留更新由 889,477 字节降为 4,113 字节,同时保留 primary 签名、完整逻辑读取、旧状态读取和幂等恢复。消费者现在通过 #1735 的依赖更新使用正式 Roost 0.2.0,不迁移或重置更早的 SQLite 容器。 + +## 决策与范围 + +- 状态只记录变化的字段、文本及数组成员,绑定 primary Turn ID,读取时重建完整应用消息。在兼容容器内,已有整条消息状态继续可读,在下次正常修改时转为差量;不重写或裁剪已封存记录及保留更新。不增加 SQLite 容器迁移。写入差量后重开数据库需要当前或更新的适配器,RPC 读取仍返回完整逻辑消息。投影复用已解码的 primary,增量生成只转换操作偏移,不再次序列化累计文本或不变正文。 +- SDK 在同一事务中准备本地命令计划与精简 pending 回执,再原子提交正文操作、发布信息、最终封存与计划退役。CLI 拥有整个数据库;Turn 导出同步已提交历史,迁移未完成的本地工作须携带包含恢复日志的完整数据库。其他宿主仍可使用 SDK 原有的可复制恢复默认模式。 +- 本决策替换此前[写放大修复](2026-10-16-roost-storage-write-amplification.zh.md)的部分处理,不改变带条件校验的原生流式写入、不可变人类输入和分支事务。 +- 通过 [#1735](https://github.com/LodyAI/Lody/pull/1735) 的依赖更新使用统一发布的 Roost 0.2.0,再合并 #1737。Native 0.2.0 要求 SQLite container v4。本改动不部署、不迁移或重置数据库、不清理历史、不截断 WAL、不合并 PR、不发包。 + +## 验收与故障边界 + +测试通过生产 SQLite 路径验证封存、封存后修正、后续流式输出、权限投影、关闭重开及原生回复丢失。大段不变文本不得出现在每条 state 更新和批处理回执中,测量保留更新字节而非 WAL 文件长度。验证 Unicode 偏移、字段删除、嵌套数组和错误基准/路径。错误状态继续使该历史读取报错,不切换后端、不重试写入。恢复日志的准备和应用故障由 SDK 处理,Lody 不另建恢复循环。 + +## 验证 + +消费者实现为 [Lody #1737](https://github.com/LodyAI/Lody/pull/1737),SDK 实现为 [Roost #16](https://github.com/loro-dev/roost/pull/16)。分支已基于 [#1735](https://github.com/LodyAI/Lody/pull/1735) rebase,使用正式 SDK 与 Native 0.2.0,锁文件精确固定版本,没有本地覆盖。此前 SDK 0.1.3 的 `TS2554` 发布阻塞已通过依赖更新解决。 + +- 两个所属 CLI 测试文件在正式 Native SQLite v4 上 **82 项通过**。覆盖旧整条消息状态重开及转换、首次状态已提交但投影前丢失回复、后续输出、权限、分支和输入合同、Unicode、字段删除、错误基准/路径及 JSON 自有键。 +- 此前的配对合成运行对照 Lody `7d6695c` + SDK 0.1.3,与本改动 + 拟发布 SDK。两边在封存 442,368 字节不变正文并修正 32 次后,均保留 **37 条更新**。更新 value 由 **889,477 降至 4,113 字节(99.54%)**,最大单条由 **443,344 降至 488 字节**。此口径不含初始 primary、outbox 副本、索引、WAL 和流传输封装。两边均保留旧帧、primary 不可变、逻辑内容及丢失回复后的重开。当前回归限制增长上界,不锁定协议精确字节数。 +- 使用正式包的完整 `pnpm check` 通过,包含所有包类型检查、普通类型感知 lint、Effect lint、仓库测试、i18n 和边界检查。CLI 全量 **4,435 项通过、4 项原有跳过**。全仓格式及文档检查通过,文档无错误。 +- 公开 CLI 在 **2 GiB 堆上限**内构建并通过发布包运行时及 Worker 探针。桌面 CLI staging 成功;真实 Electron 运行时通过 staged Native Worker 写入、校验并重新打开带签名的 SQLite 历史。四项 staging 测试覆盖六个平台的选择,其他平台用合成绑定,本机执行安装的真实绑定。这不代表本地 GUI E2E 或其他平台二进制的运行验收;正式 PR CI 已重跑。 + +先合并 #1735,再合并 #1737。本轮验收没有修改应用数据库、部署或发布包。 diff --git a/.agents/notes/proposed/architecture/2026-10-09-roost-native-runtime.md b/.agents/notes/proposed/architecture/2026-10-09-roost-native-runtime.md index 953e3c311a..de2d7245dc 100644 --- a/.agents/notes/proposed/architecture/2026-10-09-roost-native-runtime.md +++ b/.agents/notes/proposed/architecture/2026-10-09-roost-native-runtime.md @@ -21,11 +21,12 @@ history coverage and device-level responsiveness remain validation limits. ## Decision and boundaries -Pin `@loro-dev/roost-node@0.1.2` as a CLI runtime dependency and exempt only that -version and its six exact platform dependencies from the release-age policy. -Keep `@loro-dev/roost@0.1.2` for identity, -application JSON, and `NodeLodyHistory`. Neither Rust source nor a sibling Roost -checkout is needed to install, build, or package Lody. +Pin `@loro-dev/roost-node@0.2.0` as an exact CLI runtime dependency, with +`@loro-dev/roost@0.2.0` from the shared catalog for identity, application JSON, and +`NodeLodyHistory`. Exempt only these exact versions and the six matching platform +dependencies from the release-age policy. Native 0.2.0 uses SQLite container v4; +it refuses earlier containers without migration or automatic reset. Neither Rust +source nor a sibling Roost checkout is needed to install, build, or package Lody. Use `RoostNativeClient` directly. Preserve the database path, credential-store seed, admitted owner, bounded queue, shared stream leases, and awaited final close. diff --git a/.agents/notes/proposed/architecture/2026-10-09-roost-native-runtime.zh.md b/.agents/notes/proposed/architecture/2026-10-09-roost-native-runtime.zh.md index 2c621cd4d5..167e654c9c 100644 --- a/.agents/notes/proposed/architecture/2026-10-09-roost-native-runtime.zh.md +++ b/.agents/notes/proposed/architecture/2026-10-09-roost-native-runtime.zh.md @@ -17,9 +17,10 @@ CLI 现在接入已发布的 Node-API 包,在 Worker 内运行 SQLite 实现 ## 决策与边界 -CLI 运行依赖固定为 `@loro-dev/roost-node@0.1.2`,发布冷却期仅豁免该版本及 -六个精确匹配的平台包版本。 -保留 `@loro-dev/roost@0.1.2` 的身份、应用 JSON 和 `NodeLodyHistory` 适配器。 +CLI 将运行依赖精确固定为 `@loro-dev/roost-node@0.2.0`,身份、应用 JSON 和 +`NodeLodyHistory` 使用共享 catalog 的 `@loro-dev/roost@0.2.0`。 +发布冷却期仅豁免这两个精确版本和六个匹配的平台包版本。 +Native 0.2.0 使用 SQLite container v4,拒绝更早容器,不迁移或自动重置。 安装、构建和打包 Lody 无须 Rust 源码或旁边的 Roost 仓库。 直接使用 `RoostNativeClient`,保留数据库位置、系统凭据存储中的种子、允许的签名身份、 diff --git a/apps/cli/package.json b/apps/cli/package.json index 9d88d95b44..3d2607ae0a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -55,7 +55,7 @@ "@lody/shared": "workspace:*", "@lody/turn-diff-store": "workspace:*", "@loro-dev/flock-wasm": "catalog:", - "@loro-dev/roost": "0.1.3", + "@loro-dev/roost": "catalog:", "@loro-dev/streams-crdt": "catalog:", "@loro-dev/streams-client": "catalog:", "@modelcontextprotocol/client": "2.0.0", @@ -128,7 +128,7 @@ ], "dependencies": { "@effect/platform-node-shared": "catalog:", - "@loro-dev/roost-node": "0.1.2", + "@loro-dev/roost-node": "0.2.0", "@lydell/node-pty": "1.2.0-beta.14", "@napi-rs/keyring": "2.1.0", "better-sqlite3": "catalog:", diff --git a/apps/cli/scripts/check-published-bundle-imports.js b/apps/cli/scripts/check-published-bundle-imports.js index e525a41cac..cb0d761848 100644 --- a/apps/cli/scripts/check-published-bundle-imports.js +++ b/apps/cli/scripts/check-published-bundle-imports.js @@ -41,7 +41,7 @@ const requiredPublishedRuntimeDependencies = [ const requiredExactPublishedRuntimeDependencies = ['loro-crdt']; const requiredPinnedPublishedRuntimeDependencies = new Map([ ['@lydell/node-pty', '1.2.0-beta.14'], - ['@loro-dev/roost-node', '0.1.2'], + ['@loro-dev/roost-node', '0.2.0'], ]); for (const block of dependencyBlocks) { diff --git a/apps/cli/src/session/README.md b/apps/cli/src/session/README.md index 0a2087941a..3b7e6fcb2f 100644 --- a/apps/cli/src/session/README.md +++ b/apps/cli/src/session/README.md @@ -562,7 +562,17 @@ it never authorizes switching back to Loro or reading a superseded published suf ACP batch appliers can mutate item arrays. The Roost adapter applies them to a detached working copy, preserving the before-image used to decide what to persist. Content changes refresh only affected primary bodies. Sealed-turn corrections -use mutable SDK state records anchored to their primary; permission responses +use `lody-message-state` version 1 records: field, Unicode-text and array operations +relative to the exact immutable primary Turn ID. The owner reconstructs logical +entries for existing RPC readers. Whole-entry state records remain readable in a +supported container and convert on their next mutation; sealed primary bytes stay +unchanged. Reopening delta states requires this adapter or newer. SDK fallback +batches use owner-local recovery: the compact pending +receipt and private journal commit together, then effects, publication, seal and +journal retirement commit together. Moving unfinished work requires the whole +owner database and its paired host identity; Turn exports carry committed history. +The [message-copy repair](../../../../.agents/notes/implemented/bug-fix/2026-10-20-roost-message-storage-copies.md) +records the format, release dependency and measured storage reduction. Permission responses remain independent SDK records projected onto the matching tool. Responding does not seal the assistant or interrupt its output. Existing eligible unsealed primary/state records use guarded native field/text edits, diff --git a/apps/cli/src/session/roost-content-patches.ts b/apps/cli/src/session/roost-content-patches.ts index b4250dd792..5e9c15e474 100644 --- a/apps/cli/src/session/roost-content-patches.ts +++ b/apps/cli/src/session/roost-content-patches.ts @@ -76,7 +76,11 @@ export function roostContentPatches(before: unknown, after: unknown): ContentOp[ } if (object(left) && object(right)) { for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) - walk(left[key], right[key], [...path, { key }]); + walk( + Object.hasOwn(left, key) ? left[key] : undefined, + Object.hasOwn(right, key) ? right[key] : undefined, + [...path, { key }] + ); return; } ops.push({ kind: 'set', path, contentJson: encodeRoostContentText(right) }); diff --git a/apps/cli/src/session/roost-history-generation.ts b/apps/cli/src/session/roost-history-generation.ts index 75e95ee255..fc5d971eac 100644 --- a/apps/cli/src/session/roost-history-generation.ts +++ b/apps/cli/src/session/roost-history-generation.ts @@ -430,7 +430,9 @@ export class RoostHistoryGeneration { if (!verified.complete || verified.messages.length !== next.length) throw new Error('Roost staged history is incomplete'); if (options.commands?.length) - await staged.commitHistoryBatch(`stage:${operationId}`, [...options.commands]); + await staged.commitHistoryBatch(`stage:${operationId}`, [...options.commands], { + recovery: 'local', + }); const key = new TextEncoder().encode( `generation:${createHash('sha256').update(nextId).digest('hex')}` ); diff --git a/apps/cli/src/session/roost-message-state.ts b/apps/cli/src/session/roost-message-state.ts new file mode 100644 index 0000000000..747fcaa131 --- /dev/null +++ b/apps/cli/src/session/roost-message-state.ts @@ -0,0 +1,141 @@ +import { z } from 'zod'; +import { decodeJson } from '@loro-dev/roost'; +import { toApplicationJson } from '@loro-dev/roost/lody-history'; +import { roostContentPatches } from './roost-content-patches'; + +const index = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); +const path = z.array( + z.union([z.object({ key: z.string() }).strict(), z.object({ index }).strict()]) +); +const deltaSchema = z + .object({ + format: z.literal('lody-message-state'), + version: z.literal(1), + primaryTurnId: z.string().regex(/^[0-9a-f]{32}$/), + patches: z.array( + z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('set'), path, contentJson: z.string() }).strict(), + z.object({ kind: z.literal('remove'), path }).strict(), + z.object({ kind: z.literal('appendText'), path, text: z.string() }).strict(), + z + .object({ kind: z.literal('spliceText'), path, index, delete: index, text: z.string() }) + .strict(), + z + .object({ + kind: z.literal('spliceArray'), + path, + index, + delete: index, + valuesJson: z.string(), + }) + .strict(), + ]) + ), + }) + .strict(); +type StatePath = z.infer; +const object = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); +const json = (text: string): unknown => + toApplicationJson(decodeJson(new TextEncoder().encode(text))); + +/** The immutable primary is the baseline, so unchanged body bytes never enter state. */ +export function createRoostMessageState(primary: unknown, primaryTurnId: string, next: unknown) { + const patches = roostContentPatches(primary, next).map((op) => ({ + ...op, + path: op.path.slice(1).map((part) => ('key' in part ? part : { index: Number(part.index) })), + ...('index' in op ? { index: Number(op.index), delete: Number(op.delete) } : {}), + })); + // Native offsets are u64; application arrays/strings are bounded JS values. + // Store safe JSON numbers rather than serializing bigint as an application field. + return deltaSchema.parse({ + format: 'lody-message-state', + version: 1, + primaryTurnId, + patches, + }); +} + +/** Reconstruct application content without changing the signed primary. */ +export function projectRoostMessageState( + primary: unknown, + primaryTurnId: string, + stored: unknown +): unknown { + // Previous adapters stored a complete entry, always including its business ID. + if (object(stored) && typeof stored.id === 'string') return structuredClone(stored); + const delta = deltaSchema.parse(stored); + if (delta.primaryTurnId !== primaryTurnId) throw new Error('Roost state primary mismatch'); + let result: unknown = structuredClone(primary); + const member = (parent: unknown, part: StatePath[number], existing: boolean): string | number => { + if ('index' in part) { + if (!Array.isArray(parent) || part.index >= parent.length) + throw new Error('Roost state array path is invalid'); + return part.index; + } + if (!object(parent) || (existing && !Object.hasOwn(parent, part.key))) + throw new Error('Roost state object path is invalid'); + return part.key; + }; + const read = (parts: StatePath): unknown => { + let value = result; + for (const part of parts) { + const key = member(value, part, true); + value = Reflect.get(value as object, key); + } + return value; + }; + const write = (parts: StatePath, value: unknown, remove = false) => { + const last = parts.at(-1); + if (!last) { + if (remove) throw new Error('Roost state cannot remove its root'); + result = value; + return; + } + const parent = read(parts.slice(0, -1)); + const key = member(parent, last, remove); + if (remove) { + if (Array.isArray(parent)) throw new Error('Roost state cannot remove an array index'); + Reflect.deleteProperty(parent as object, key); + } else + Object.defineProperty(parent, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + }; + for (const op of delta.patches) { + if (op.kind === 'set') write(op.path, json(op.contentJson)); + else if (op.kind === 'remove') write(op.path, undefined, true); + else { + const value = read(op.path); + if (op.kind === 'spliceArray') { + const values = json(op.valuesJson); + if ( + !Array.isArray(value) || + !Array.isArray(values) || + op.index > value.length || + op.delete > value.length - op.index + ) + throw new Error('Roost state array splice is invalid'); + write(op.path, value.slice(0, op.index).concat(values, value.slice(op.index + op.delete))); + } else { + if (typeof value !== 'string') throw new Error('Roost state text path is invalid'); + if (op.kind === 'appendText') write(op.path, value + op.text); + else { + const scalars = Array.from(value); + if (op.index > scalars.length || op.delete > scalars.length - op.index) + throw new Error('Roost state text splice is invalid'); + write( + op.path, + scalars.slice(0, op.index).join('') + + op.text + + scalars.slice(op.index + op.delete).join('') + ); + } + } + } + } + return result; +} diff --git a/apps/cli/src/session/roost-node-session.ts b/apps/cli/src/session/roost-node-session.ts index 4397e046e4..b79e981844 100644 --- a/apps/cli/src/session/roost-node-session.ts +++ b/apps/cli/src/session/roost-node-session.ts @@ -81,6 +81,7 @@ import { registerSessionBackendFactory } from './session-backend'; import { RoostHistoryGeneration } from './roost-history-generation'; import { createRoostHistoryChanges } from './roost-history-changes'; import { roostContentPatches, agentReceiptPut } from './roost-content-patches'; +import { createRoostMessageState, projectRoostMessageState } from './roost-message-state'; import { reportRoostHistoryFailure } from './roost-history-diagnostics'; import { loadRoostOwnerSeedLegacy } from './roost-writer-identity'; import { @@ -321,7 +322,13 @@ const createNodeServicesForLease = async ( const projectMessages = async ( messages: readonly HistoryProjectedMessage[] ): Promise => { - const projected = projectRoostSegments(adaptRoostProjectedMessages(messages)) as SessionEntry[]; + const decoded = adaptRoostProjectedMessages(messages); + const projected = projectRoostSegments(decoded) as SessionEntry[]; + const primaryContent = new Map( + decoded + .filter((row) => row.segmentId === 'primary') + .map((row) => [row.businessId, row.content]) + ); const primaries = new Map( messages.filter((row) => row.segmentId === 'primary').map((row) => [row.businessId, row]) ); @@ -332,7 +339,14 @@ const createNodeServicesForLease = async ( const project = async (entry: SessionEntry): Promise => { const primary = primaries.get(entry.id); const state = primary ? await readState(primary) : undefined; - let turn = state ? (toApplicationJson(state.content) as SessionEntry) : entry; + let turn = + state && primary + ? (projectRoostMessageState( + primaryContent.get(entry.id), + bytesHex(primary.turnId), + toApplicationJson(state.content) + ) as SessionEntry) + : entry; if (turn.id !== entry.id) throw new Error('Roost state record identity mismatch'); const items = [...(turn.items ?? [])]; for (const [index, item] of items.entries()) { @@ -1269,19 +1283,22 @@ const createNodeServicesForLease = async ( const state = await readState(primary); if (state || primary.sealed) { const identity = stateIdentity(primary); + const content = encodeRoostContent( + createRoostMessageState(toApplicationJson(primary.content), bytesHex(primary.turnId), after) + ); return [ state ? { type: 'setContent', identity, expectedNextSeq: state.nextSeq, - content: encodeRoostContent(after), + content, } : { type: 'accept', identity, parents: primary.sealedHash ? [{ id: primary.turnId, hash: primary.sealedHash }] : [], - content: encodeRoostContent(after), + content, }, ]; } @@ -1400,7 +1417,14 @@ const createNodeServicesForLease = async ( canPatch = false; break; } - const ops = roostContentPatches(toApplicationJson(target.content), entry); + const content = state + ? createRoostMessageState( + toApplicationJson(primary.content), + bytesHex(primary.turnId), + entry + ) + : entry; + const ops = roostContentPatches(toApplicationJson(target.content), content); if (ops.length) { patchWrites.push({ kind: 'edit', @@ -1450,7 +1474,8 @@ const createNodeServicesForLease = async ( } else { await roostHistory.commitHistoryBatch( `${operationId}:${randomBytes(8).toString('hex')}`, - commands + commands, + { recovery: 'local' } ); await refreshTurnProjections(changed); for (const entry of prepared) rememberGoal(entry, positions.get(entry.id)); @@ -1662,7 +1687,8 @@ const createNodeServicesForLease = async ( outcome: encodeRoostContent(validated), parents: [], }, - ] + ], + { recovery: 'local' } ); permissionCache.delete(requestId); await refreshTurnProjections([read.turn.id]); diff --git a/apps/cli/tests/roost-message-state.test.ts b/apps/cli/tests/roost-message-state.test.ts new file mode 100644 index 0000000000..5cb879d947 --- /dev/null +++ b/apps/cli/tests/roost-message-state.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { + createRoostMessageState, + projectRoostMessageState, +} from '../src/session/roost-message-state'; + +const primaryId = '01'.repeat(16); +describe('Roost message state', () => { + it('preserves the sealed baseline and reconstructs Unicode, deletions and nested arrays', () => { + const primary = { + id: 'assistant', + finished: true, + endedAt: 42, + items: [{ type: 'text', text: 'a🙂bc🚀d' }, { output: { rows: ['one', 'two'] } }], + unknown: { preserved: 'large unchanged value'.repeat(4096) }, + }; + const original = structuredClone(primary); + const next = { + id: 'assistant', + finished: false, + items: [ + { type: 'text', text: 'a🙂XYZ🚀d' }, + { output: { rows: ['changed'] } }, + { flag: null }, + ], + unknown: primary.unknown, + }; + const state = createRoostMessageState(primary, primaryId, next); + expect(JSON.stringify(state).length).toBeLessThan(2048); + expect(projectRoostMessageState(primary, primaryId, state)).toEqual(next); + expect(primary).toEqual(original); + expect(projectRoostMessageState(primary, primaryId, original)).toEqual(original); + }); + + it('supports optional-field absence and increasing corrections relative to the same primary', () => { + const primary = { id: 'a', text: '😀prefix', list: [1, 2, 3], optional: 'remove' }; + for (let size = 0; size < 32; size++) { + const next = { + id: 'a', + text: primary.text + '🙂'.repeat(size), + list: Array.from({ length: size }, (_, n) => n), + optional: undefined, + }; + const state = createRoostMessageState(primary, primaryId, next); + expect(projectRoostMessageState(primary, primaryId, state)).toEqual( + JSON.parse(JSON.stringify(next)) + ); + } + }); + + it('rejects another primary, invalid offsets and inherited paths', () => { + const primary = { id: 'a', text: '🙂' }; + const state = createRoostMessageState(primary, primaryId, { ...primary, text: 'next' }); + expect(() => projectRoostMessageState(primary, '02'.repeat(16), state)).toThrow( + 'primary mismatch' + ); + for (const patches of [ + [{ kind: 'spliceText', path: [{ key: 'text' }], index: 2, delete: 0, text: 'bad' }], + [{ kind: 'set', path: [{ key: '__proto__' }, { key: 'polluted' }], contentJson: 'true' }], + [{ kind: 'set', path: [{ index: -1 }], contentJson: 'null' }], + ]) + expect(() => projectRoostMessageState(primary, primaryId, { ...state, patches })).toThrow(); + expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false); + }); + + it('keeps arbitrary own JSON keys without modifying prototypes', () => { + const primary = { id: 'a' }; + const next = JSON.parse('{"id":"a","__proto__":{"polluted":true}}'); + expect( + projectRoostMessageState( + primary, + primaryId, + createRoostMessageState(primary, primaryId, next) + ) + ).toEqual(next); + expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false); + }); +}); diff --git a/apps/cli/tests/roost-session-backend-contract.test.ts b/apps/cli/tests/roost-session-backend-contract.test.ts index 15f8f4b214..92c8aabd3d 100644 --- a/apps/cli/tests/roost-session-backend-contract.test.ts +++ b/apps/cli/tests/roost-session-backend-contract.test.ts @@ -1190,6 +1190,109 @@ describe('Production Roost history semantics', () => { }); }); + it('reads legacy whole-message states across reopen and converts only the next mutation', async () => { + let fixture = await nativeFixture(); + const turn = { ...nativeAssistant('legacy-state'), finished: true, + items: [{ type: 'text' as const, text: 'sealed body' }] }; + await fixture.backend.appendHistoryTurn(turn); + const legacy = { ...turn, items: [{ type: 'text' as const, text: 'legacy correction' }] }; + const batch = NodeLodyHistory.prototype.commitHistoryBatch; + const previous = vi.spyOn(NodeLodyHistory.prototype, 'commitHistoryBatch').mockImplementation(async function (batchId, commands, options) { + return batch.call(this, batchId, commands.map(command => + command.type === 'accept' && command.identity.segmentId.startsWith('state:') + ? { ...command, content: encodeRoostContent(legacy) } : command + ), options); + }); + try { + await fixture.backend.applyHistoryAction({ kind: 'assistant-items', turnId: turn.id, + mode: 'replace', items: legacy.items }); + } finally { previous.mockRestore(); } + const sessionId = fixture.doc.sessionId; + await fixture.close(); + fixture = await nativeFixture(sessionId); + expect(await fixture.backend.readTurn(turn.id)).toMatchObject({ state: 'ready', turn: legacy }); + const items = [{ type: 'text' as const, text: 'next correction 🙂' }]; + await fixture.backend.applyHistoryAction({ kind: 'assistant-items', turnId: turn.id, + mode: 'replace', items }); + await fixture.close(); + fixture = await nativeFixture(sessionId); + expect(await fixture.backend.readTurn(turn.id)).toMatchObject({ + state: 'ready', turn: { ...legacy, items }, + }); + }); + + it('keeps post-seal state updates bounded and recovers a lost first-state reply', async () => { + const fixture = await nativeFixture(); + const untouched = 'sealed synthetic tool body '.repeat(16384); + const tool = { type: 'tool_call' as const, toolCallId: 'large-tool', status: 'completed' as const, + title: 'Synthetic tool', rawOutput: { text: untouched } }; + const turn = { + ...nativeAssistant('sealed-state-budget'), + items: [ + tool, + { type: 'text' as const, text: 'base🙂' }, + ], + }; + let primaryId: Uint8Array | undefined; + const accept = NodeLodyHistory.prototype.acceptToView; + const capture = vi.spyOn(NodeLodyHistory.prototype, 'acceptToView').mockImplementation(async function (...args) { + const result = await accept.apply(this, args); + if (args[0].input.businessId === turn.id) primaryId = result.turnId.slice(); + return result; + }); + try { await fixture.backend.appendHistoryTurn(turn); } + finally { capture.mockRestore(); } + if (!primaryId) throw new Error('Expected primary'); + await fixture.backend.applyHistoryAction({ kind: 'finish-assistant', turnId: turn.id, endedAt: 42 }); + const updates = () => { + const db = new Database(join(nativeDirectory, 'history.db'), { readonly: true }); + try { + const kv = db.pragma('user_version', { simple: true }) === 4 + ? '(SELECT roost_spaces.name AS space, key, value FROM roost_kv JOIN roost_spaces ON roost_spaces.id = roost_kv.space_id)' + : 'roost_kv'; + return db.prepare(`SELECT key, value FROM ${kv} WHERE space LIKE ?`) + .all(`%${fixture.doc.sessionId}%:updates`) as { key: Buffer; value: Buffer }[]; + } finally { db.close(); } + }; + const before = new Map(updates().map(row => [row.key.toString('hex'), row.value])); + let items = turn.items; + const batch = NodeLodyHistory.prototype.commitHistoryBatch; + const lost = vi.spyOn(NodeLodyHistory.prototype, 'commitHistoryBatch').mockImplementationOnce(async function (...args) { + await batch.apply(this, args); + throw new Error('Lost first-state reply'); + }); + items = [tool, { type: 'text', text: 'base🙂🚀' }]; + try { + await expect(fixture.backend.applyHistoryAction({ kind: 'assistant-items', turnId: turn.id, + mode: 'replace', items })).rejects.toThrow('Lost first-state reply'); + } finally { lost.mockRestore(); } + expect(await fixture.backend.readTurn(turn.id)).toMatchObject({ state: 'ready', turn: { items } }); + for (let step = 1; step < 32; step++) { + items = [tool, { type: 'text', text: `base🙂${'🚀'.repeat(step + 1)}` }]; + await fixture.backend.applyHistoryAction({ kind: 'assistant-items', turnId: turn.id, mode: 'replace', items }); + } + await fixture.backend.flushLocalWrites(); + const after = updates(); + for (const row of after) { + const old = before.get(row.key.toString('hex')); + if (old) expect(row.value.equals(old)).toBe(true); + } + expect(after.filter(row => before.has(row.key.toString('hex'))).length).toBe(before.size); + const added = after.filter(row => !before.has(row.key.toString('hex'))); + expect(added.reduce((sum, row) => sum + row.value.length, 0)).toBeLessThan(128 * 1024); + for (const row of added) { + expect(row.key.subarray(0, 16).equals(Buffer.from(primaryId))).toBe(false); + expect(row.value.length).toBeLessThan(16 * 1024); + } + const sessionId = fixture.doc.sessionId; + await fixture.close(); + const reopened = await nativeFixture(sessionId); + expect(await reopened.backend.readTurn(turn.id)).toMatchObject({ + state: 'ready', turn: { items, finished: true, endedAt: 42 }, + }); + expect((await reopened.backend.readHistory()).length).toBe(1); + }); + it('observes owner checkpoints without holding the writer and closes waits on cancellation', async () => { const fixture = await nativeFixture(); const history = fixture.backend.history; @@ -2516,18 +2619,25 @@ describe('Production Roost history semantics', () => { } const db = new Database(join(nativeDirectory, 'history.db'), { readonly: true }); try { + const containerVersion = db.pragma('user_version', { simple: true }); + expect([3, 4]).toContain(containerVersion); + const kvRows = + containerVersion === 4 + ? `(SELECT roost_spaces.name AS space, key, value + FROM roost_kv JOIN roost_spaces ON roost_spaces.id = roost_kv.space_id)` + : 'roost_kv'; const stats = db .prepare(`SELECT COUNT(*) AS rows, SUM(LENGTH(key) + LENGTH(value)) AS bytes - FROM roost_kv WHERE space LIKE ?`) + FROM ${kvRows} WHERE space LIKE ?`) .get(`%${fixture.doc.sessionId}%:updates`) as { rows: number; bytes: number }; expect(stats.rows).toBeGreaterThan(128); expect(stats.bytes).toBeLessThan(1024 * 1024); const receipts = db - .prepare(`SELECT COUNT(*) AS count FROM roost_kv WHERE space LIKE ?`) + .prepare(`SELECT COUNT(*) AS count FROM ${kvRows} WHERE space LIKE ?`) .get(`%${fixture.doc.sessionId}%:lody_agent_operation_receipts_v1`) as { count: number }; expect(receipts.count).toBe(128); const projection = db - .prepare(`SELECT COUNT(*) AS count FROM roost_kv WHERE space LIKE ?`) + .prepare(`SELECT COUNT(*) AS count FROM ${kvRows} WHERE space LIKE ?`) .get(`%${fixture.doc.sessionId}%:projection`) as { count: number }; expect(projection.count).toBeGreaterThan(0); expect(projection.count).toBeLessThan(1000); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 676d35287d..638c366057 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ catalogs: '@loro-dev/loro-cli': specifier: 0.6.0 version: 0.6.0 + '@loro-dev/roost': + specifier: 0.2.0 + version: 0.2.0 '@loro-dev/streams-client': specifier: 0.12.1 version: 0.12.1 @@ -260,8 +263,8 @@ importers: specifier: 'catalog:' version: 4.0.2(effect@4.0.2) '@loro-dev/roost-node': - specifier: 0.1.2 - version: 0.1.2 + specifier: 0.2.0 + version: 0.2.0 '@lydell/node-pty': specifier: 1.2.0-beta.14 version: 1.2.0-beta.14 @@ -321,8 +324,8 @@ importers: specifier: 'catalog:' version: 0.4.4 '@loro-dev/roost': - specifier: 0.1.3 - version: 0.1.3 + specifier: 'catalog:' + version: 0.2.0 '@loro-dev/streams-client': specifier: 'catalog:' version: 0.12.1(effect@4.0.2) @@ -3857,50 +3860,50 @@ packages: engines: {node: '>=22'} hasBin: true - '@loro-dev/roost-node-darwin-arm64@0.1.2': - resolution: {integrity: sha512-DcbHhL7jTm7itxxBpLoSf14836OH9q9tZz+UZClS0Dy3wf/qZvhaItlZXOu4f4aXBlcOvs/3oA3MS0y52vYnbA==} + '@loro-dev/roost-node-darwin-arm64@0.2.0': + resolution: {integrity: sha512-7Y85fxFS/yS+cXhaRfazCNh76MDp/FDGfbO8pIDNB0Az2D0DITzBHO495FTUkniCLJSyd6tjFhR+qmzBLEweGQ==} engines: {node: '>=22.14.0'} cpu: [arm64] os: [darwin] - '@loro-dev/roost-node-darwin-x64@0.1.2': - resolution: {integrity: sha512-e2aoEBomOPmJrGAedRJRQsEiZGhMq/z8nkPsrl25JM51YCrFynVkXw3Zqb27svyMsbYPsiPJuGE6hpDOStcjyQ==} + '@loro-dev/roost-node-darwin-x64@0.2.0': + resolution: {integrity: sha512-cU/GILiSEto9gO4WR2aOABoqwG11+s/AWStjZ/PwteSrhR+81WLn4ZSlrXW0gtK8Tvu3KOFzuBC3m+LE3eyiGQ==} engines: {node: '>=22.14.0'} cpu: [x64] os: [darwin] - '@loro-dev/roost-node-linux-arm64-gnu@0.1.2': - resolution: {integrity: sha512-lzk+HzP9GouzvGv5eyIaqHxfiafj9Xc+8+9prSXTTK3y3imWzC1esPNyyfkdKszhRlkPeTSansl26Vs6TmZeNQ==} + '@loro-dev/roost-node-linux-arm64-gnu@0.2.0': + resolution: {integrity: sha512-rG4/fK3UlrJQtWz6aAJ7kUWUN/8iDz7RScgqYhtMgp4LjyTnu+HJ8r9Nh1yLGBQOSCv+qMG89nYdK6R0PVWSZQ==} engines: {node: '>=22.14.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@loro-dev/roost-node-linux-x64-gnu@0.1.2': - resolution: {integrity: sha512-2sMZDy1dolPJnDIyr//1SIvcvmPt/Frkdth9+fcmCRY0wQmyeOyXnQTVWAFGRHeYCIuFGB7Vqn4oMb05sRwV0w==} + '@loro-dev/roost-node-linux-x64-gnu@0.2.0': + resolution: {integrity: sha512-htuaQOBfs1qMdJZ7uK2d1Rm7zVNrsFmO6oQj/u70cu1ZWl10Hn6fe4NaiviNvQPcap4CEEdoY3grySvQaDwzpg==} engines: {node: '>=22.14.0'} cpu: [x64] os: [linux] libc: [glibc] - '@loro-dev/roost-node-win32-arm64-msvc@0.1.2': - resolution: {integrity: sha512-muMxGHitya4DGKbuM9gH4UWY/AIeFc3InZgYC0nBiNliVltaBcxUUc177PumvU1Ft+AajsyHEHz27fmAUYjm9A==} + '@loro-dev/roost-node-win32-arm64-msvc@0.2.0': + resolution: {integrity: sha512-phsf9p+53sx8Z5ERkf4iZnQEZs3hWRkXkxsMx07IPyydvSdHxW8LEkQHFPTtNw4q0G7EM5uZuEqlAWAbQrf74A==} engines: {node: '>=22.14.0'} cpu: [arm64] os: [win32] - '@loro-dev/roost-node-win32-x64-msvc@0.1.2': - resolution: {integrity: sha512-ZBVS2oYXaZufO7pxQgIX5iXkthlhJe44Xer6taFC1wgFpcfhgtT90yif+tnFum6T3x6SlF8pzqLR3xQSd/vYiw==} + '@loro-dev/roost-node-win32-x64-msvc@0.2.0': + resolution: {integrity: sha512-cDpciAjAWHoALXiZLTmK/XwS0cpkJ6//bLV7XZ6FXqNuZYzm+wFxl7eiYEG6wS3++g5wRB1qBo6MgLXdbZyt2Q==} engines: {node: '>=22.14.0'} cpu: [x64] os: [win32] - '@loro-dev/roost-node@0.1.2': - resolution: {integrity: sha512-A8RiMXtmjfiI2qNzJ7AhAMsft/am9phNiXVnhnOqy4V9/oFzEYTvsAfoXua0cqq5UhaXP4Xy2R6j+QSb6Z7dtA==} + '@loro-dev/roost-node@0.2.0': + resolution: {integrity: sha512-+3rUDTutVxsouuQmySgM0AZUmPYiVUzlF2vT1vEJeI5QCZQhxNvSPLpe1faXpm+iFN8a6eCl/NUpO/ERdLteRA==} engines: {node: '>=22.14.0'} - '@loro-dev/roost@0.1.3': - resolution: {integrity: sha512-OAXB26dAZ7w80YlKoqHFotQ07nYnWrtW0M915cIyi7LUf7UICQ4lc2ZGBlbrydmuqtyNNXtTzW0hOpHu3fBrNQ==} + '@loro-dev/roost@0.2.0': + resolution: {integrity: sha512-nfWj8wk7q5ZbHCWPqCqptaA6QgYkdcf46JB7M4x2OkpC2gAYDJ8pdwWTMsGpGYPNyCsNqjxhU3T7seQyXe1S4w==} '@loro-dev/streams-client@0.10.0': resolution: {integrity: sha512-FWyrr7AnhJZXsrxubqjjgabD1aRksziU8HNR3WiJoYaPQEX0kDwiqqAJ53xgYI7MurQNfUcGVmSw0aD+TCNSMA==} @@ -16602,34 +16605,34 @@ snapshots: js-yaml: 4.1.1 loro-crdt: 1.16.3 - '@loro-dev/roost-node-darwin-arm64@0.1.2': + '@loro-dev/roost-node-darwin-arm64@0.2.0': optional: true - '@loro-dev/roost-node-darwin-x64@0.1.2': + '@loro-dev/roost-node-darwin-x64@0.2.0': optional: true - '@loro-dev/roost-node-linux-arm64-gnu@0.1.2': + '@loro-dev/roost-node-linux-arm64-gnu@0.2.0': optional: true - '@loro-dev/roost-node-linux-x64-gnu@0.1.2': + '@loro-dev/roost-node-linux-x64-gnu@0.2.0': optional: true - '@loro-dev/roost-node-win32-arm64-msvc@0.1.2': + '@loro-dev/roost-node-win32-arm64-msvc@0.2.0': optional: true - '@loro-dev/roost-node-win32-x64-msvc@0.1.2': + '@loro-dev/roost-node-win32-x64-msvc@0.2.0': optional: true - '@loro-dev/roost-node@0.1.2': + '@loro-dev/roost-node@0.2.0': optionalDependencies: - '@loro-dev/roost-node-darwin-arm64': 0.1.2 - '@loro-dev/roost-node-darwin-x64': 0.1.2 - '@loro-dev/roost-node-linux-arm64-gnu': 0.1.2 - '@loro-dev/roost-node-linux-x64-gnu': 0.1.2 - '@loro-dev/roost-node-win32-arm64-msvc': 0.1.2 - '@loro-dev/roost-node-win32-x64-msvc': 0.1.2 + '@loro-dev/roost-node-darwin-arm64': 0.2.0 + '@loro-dev/roost-node-darwin-x64': 0.2.0 + '@loro-dev/roost-node-linux-arm64-gnu': 0.2.0 + '@loro-dev/roost-node-linux-x64-gnu': 0.2.0 + '@loro-dev/roost-node-win32-arm64-msvc': 0.2.0 + '@loro-dev/roost-node-win32-x64-msvc': 0.2.0 - '@loro-dev/roost@0.1.3': + '@loro-dev/roost@0.2.0': dependencies: '@loro-dev/streams-client': 0.8.0 '@noble/curves': 2.4.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1a057fb22c..90c7196f7e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -28,6 +28,7 @@ catalog: '@devframes/plugin-terminals--assets': 1.0.0 '@loro-dev/flock-wasm': ^0.4.4 '@loro-dev/loro-cli': 0.6.0 + '@loro-dev/roost': 0.2.0 '@loro-dev/sqlite-riverrun': 0.2.0 '@loro-dev/streams-client': 0.12.1 '@loro-dev/streams-crdt': 0.19.1 @@ -87,15 +88,15 @@ minimumReleaseAgeExclude: - '@effect/vitest@4.0.2' - '@effect/platform-node-shared@4.0.2' # Keep the split release's complete optional platform graph available on every host. - - '@loro-dev/roost-node@0.1.2' - - '@loro-dev/roost-node-darwin-arm64@0.1.2' - - '@loro-dev/roost-node-darwin-x64@0.1.2' - - '@loro-dev/roost-node-linux-arm64-gnu@0.1.2' - - '@loro-dev/roost-node-linux-x64-gnu@0.1.2' - - '@loro-dev/roost-node-win32-arm64-msvc@0.1.2' - - '@loro-dev/roost-node-win32-x64-msvc@0.1.2' + - '@loro-dev/roost-node@0.2.0' + - '@loro-dev/roost-node-darwin-arm64@0.2.0' + - '@loro-dev/roost-node-darwin-x64@0.2.0' + - '@loro-dev/roost-node-linux-arm64-gnu@0.2.0' + - '@loro-dev/roost-node-linux-x64-gnu@0.2.0' + - '@loro-dev/roost-node-win32-arm64-msvc@0.2.0' + - '@loro-dev/roost-node-win32-x64-msvc@0.2.0' # Use the pinned npm release instead of requiring a sibling Roost checkout. - - '@loro-dev/roost@0.1.3' + - '@loro-dev/roost@0.2.0' # pnpm 10.20 uses the first rule per package; keep exact Codex versions together. - '@openai/codex@0.154.0 || 0.156.0 || 0.156.0-darwin-arm64 || 0.156.0-darwin-x64 || 0.156.0-linux-arm64 || 0.156.0-linux-x64 || 0.156.0-win32-arm64 || 0.156.0-win32-x64 || 0.159.2 || 0.159.2-darwin-arm64 || 0.159.2-darwin-x64 || 0.159.2-linux-arm64 || 0.159.2-linux-x64 || 0.159.2-win32-arm64 || 0.159.2-win32-x64' # Exact transitive dependencies already selected for werift.