diff --git a/.agents/notes/implemented/bug-fix/2026-09-13-session-archive-complete-metadata-query.md b/.agents/notes/implemented/bug-fix/2026-09-13-session-archive-complete-metadata-query.md new file mode 100644 index 000000000..411641e35 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-13-session-archive-complete-metadata-query.md @@ -0,0 +1,61 @@ +# Make cold-start archive discovery and commit failure-safe + +Status: implemented +Translation: current + +Contract: [Session relations and operation targets](../../../../specs/session-relations.md) +Implementation: [#658](https://github.com/LodyAI/Lody/pull/658) + +[中文](2026-09-13-session-archive-complete-metadata-query.zh.md) + +## Abstract + +An interactive root Session could be archived before the client metadata projection +contained its direct child Tabs. Archive now discovers targets from a per-action +repository metadata query and commits direct children before the root. Failed writes +enter compensation before the action rejects, and terminal cleanup starts only after +the metadata set commits. + +## Decision + +The archive action obtains the workspace metadata index before authoring any state +change. It normalizes Session ids from room ids, selects only direct `parentSessionId` +children, and rechecks that the captured workspace runtime is still active before +writing. The rendered root remains a fallback when the query lags that already-visible +document, but descendant discovery never falls back to the UI cache. “Complete” means +the repository snapshot observed by the query, not children created after it. + +Waiting for `docMetaCacheReadyAtom` was rejected. Readiness belongs to an asynchronous +UI projection whose live-event metadata fetch can fail or remain unresolved; making a +user action wait for that global signal would introduce an unbounded pending state. +The repository index is already the source used to build that projection and gives the +archive action an explicit success or failure boundary. + +LoroRepo does not provide a cross-document rollback transaction. The action therefore +writes children before the root and treats the root write as its final commit point. On +failure it attempts to restore every attempted target's prior `isArchived` and `status` +values. Root compensation happens first; if it fails, children remain archived and an +error reports both the write and rollback failures, preserving the root-archived +implication. The captured runtime stays authoritative after the first write, so a +workspace switch cannot split one commit across runtimes. Terminal closure is a +best-effort post-commit cleanup: metadata failure closes no terminal, while an IPC +failure cannot undo or hide an already durable archive. + +This change intentionally leaves restore and archived-root deletion behavior unchanged. +It does not expand lifecycle ownership to `openedBySessionId` or +`openedByRootSessionId`; independent Sessions continue to survive opener archive. + +## Verification + +The owning hook suite exercises the production `getMeta().scan()` path with a UI cache +containing only the root while the repository contains its direct child and independently +opened Sessions. It verifies the target set and terminal set, child-write and final root +write compensation, zero terminal effects on metadata failure, and both workspace switch +boundaries: abort before the first write and finish against the captured runtime after it. + +This was the initial mitigation for [#574](https://github.com/LodyAI/Lody/issues/574). +The coordinated local topology now supersedes its compensation path with the +[durable lifecycle operation design](../../proposed/architecture/2026-09-13-session-lifecycle-commit.md); +cloud and dual topologies retain this implementation until their independently +deployed writers can be fenced. The containment decision remains in +[Keep opened Sessions outside opener state cascades](2026-09-10-session-containment-lifecycle.md). diff --git a/.agents/notes/implemented/bug-fix/2026-09-13-session-archive-complete-metadata-query.zh.md b/.agents/notes/implemented/bug-fix/2026-09-13-session-archive-complete-metadata-query.zh.md new file mode 100644 index 000000000..6208e3be1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-13-session-archive-complete-metadata-query.zh.md @@ -0,0 +1,48 @@ +# 让冷启动归档的目标发现与提交具备失败安全性 + +Status: implemented +Translation: current + +Contract: [Session 关系与操作目标](../../../../specs/session-relations.md) +实现:[PR #658](https://github.com/LodyAI/Lody/pull/658) + +[English](2026-09-13-session-archive-complete-metadata-query.md) + +## 摘要 + +根 Session 已可交互时,客户端元数据投影可能还没有包含它的直接子 Tab,导致过早归档。 +现在每次归档都会查询仓库元数据来发现目标,并按先直接子项、后根 Session 的顺序提交。 +写入失败会先进入补偿再拒绝操作;只有整组元数据提交成功后,才开始清理终端。 + +## 决策 + +归档操作会在写入任何状态之前取得工作区元数据索引。它从 room id 补全 Session id,只选择直接的 +`parentSessionId` 子项,并在写入前重新确认捕获的工作区 runtime 仍然处于活动状态。当索引暂时落后于 +已经可见的根 Session 时,仍可回退到已渲染的根元数据;但发现后代时绝不回退到 UI 缓存。 +这里的“完整”仅指本次查询所观察到的仓库快照,不包括快照之后新建的子项。 + +我们没有选择等待 `docMetaCacheReadyAtom`。就绪状态属于异步 UI 投影;实时事件触发的元数据读取可能失败或 +长期不返回。让用户操作等待这个全局信号会引入无期限 pending 状态。仓库索引本就是构建该投影的数据源, +并且能为归档操作提供明确的成功或失败边界。 + +LoroRepo 不提供跨文档回滚事务。因此归档先写子项,最后写根 Session,并把根写入作为最终提交点。 +发生失败时,会尝试恢复所有已尝试目标原有的 `isArchived` 与 `status`。根 Session 会先补偿;若根补偿 +也失败,则保留子项的已归档状态,并在同一错误中同时报告写入与补偿失败,从而保持“根已归档则子项也已归档”。 +第一笔写入开始后,操作始终固定在捕获到的 runtime 上,因此切换工作区不会把同一次提交拆到两个 +runtime。终端关闭是提交后的尽力清理:元数据失败不会关闭任何终端,而 IPC 失败也不会撤销或掩盖已经 +持久化的归档。 + +本次改动刻意不改变恢复与归档根永久删除的行为。它也不会把生命周期所有权扩大到 +`openedBySessionId` 或 `openedByRootSessionId`;独立 Session 在开启者被归档后继续存活。 + +## 验证 + +所属 hook 测试通过生产 `getMeta().scan()` 路径执行归档:UI 缓存只包含根 Session,而仓库同时包含 +直接子项和独立打开的 Session。测试覆盖目标与终端集合、子项写入失败与最终根写入失败的补偿、 +元数据失败时不关闭终端,以及切换工作区的两个边界:首笔写入前中止,首笔写入后继续在捕获的 runtime +完成提交。 + +这是 [#574](https://github.com/LodyAI/Lody/issues/574) 的初始缓解方案。协调升级的本地拓扑现在以 +[持久 lifecycle 操作设计](../../proposed/architecture/2026-09-13-session-lifecycle-commit.zh.md) +取代其补偿路径;cloud 与 dual 拓扑会保留本实现,直到能够隔离独立发布的 writer。包含关系决策仍由 +[让被打开的 Session 不受开启者状态级联影响](2026-09-10-session-containment-lifecycle.zh.md)维护。 diff --git a/.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md b/.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md new file mode 100644 index 000000000..85fdacc86 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md @@ -0,0 +1,155 @@ +# Commit Session lifecycle operations as one durable fact + +Status: proposed +Translation: current + +Contract: [Session relations](../../../../specs/session-relations.md) + +[中文](2026-09-13-session-lifecycle-commit.zh.md) + +## Abstract + +The legacy product topology compensates failed metadata writes by restoring a previously +read snapshot, which can overwrite legitimate concurrent writes and can itself leave +only some targets changed. The local OSS topology now records each archive or restore as one +immutable operation and derives effective Session lifecycle state through a shared +repository projection. The operation is the unit of conflict resolution, persistence, +and publication; resource cleanup follows the resulting state. Real dependency probes +establish local WASM rollback, and real IndexedDB, SQLite, and LoroRepo tests cover the +replacement boundary. Product mixed-client admission remains unavailable, so the wider +rollout remains proposed and #574 is not complete for that topology. + +## Decision and scope + +Keep repository-based discovery of the selected Session and direct `parentSessionId` +children, explicit workspace ownership, and post-commit terminal cleanup. Replace +`writeArchiveStateFailureSafe`, ordering-dependent writes, `attemptedTargets`, old-value +compensation, and rollback-error aggregation with a repository lifecycle command. +Archive and restore move together because both write the same authority. + +One operation freezes its target ids, desired archived state, stable identity, and +ordering information. Its payload is immutable across retries. The shared resolver +orders whole operations and publishes one effective metadata revision; it never +persists an independently authoritative flag for every target. Independent Tab actions +remain valid and use the same operation model with a singleton target set. Execution +status remains runtime-owned and is never restored from a lifecycle snapshot. + +Each target takes the highest ordered operation covering it. Identical frozen root +sets choose one winner together; different sets retain earlier results for targets +absent from the newer operation. This is deterministic operation precedence, not a +promise that all children always have the root's state. + +Durable admission includes crash-safe local order allocation and recovery of admitted +but unpublished records before accepting new commands. Publication and recovery allow +duplicate delivery of the same operation or revision; subscribers and resource effects +are idempotent rather than claiming exactly-once notifications. Resource work must also +avoid tearing down a newer runtime generation after restore. + +This is a lifecycle-specific protocol, not a generic saga, command queue, or distributed +database transaction framework. A compatible client still authors locally against its +own repository. No daemon proxy author, authenticated cloud requirement, or hosted +implementation is introduced into the public desktop. + +The frozen v1 wire and admission layout are: + +| Boundary | v1 contract | +| --- | --- | +| Replicated record | One canonical JSON string per immutable operation at metadata document `_lody/session-lifecycle-operations/v1`, field `operation:`. | +| Ordering | Canonical non-negative decimal Lamport counter, then UTF-8 byte order of `actorId` and `operationId`. | +| Browser admission | IndexedDB `lody-session-lifecycle-v1:`, with `admissions` and `state` object stores. | +| CLI admission | Dedicated `session-lifecycle.sqlite3` under the workspace Loro storage directory, with operation and high-water tables. | +| Result | A durable receipt distinguishes `published` from `pending`; an unconfirmed storage result carries the same queryable operation id. | +| Migration | Existing archived rows seed deterministic counter-zero `baseline:v1:` operations. Active rows remain the default baseline. | + +Admission and local high-water allocation share one storage transaction. The owner +installs the complete resolver revision before notifying per-Session readers, replays +unpublished admissions at startup, rejects conflicting reuse of an operation id, and +does not compact v1 history. Direct legacy `isArchived` writes are rejected after local +activation, except an initial `false` for a Session with no lifecycle winner. + +## Dependency evidence + +Inspection used Lody commit `54623883be77bd17f9dab18ef5a60cc2a9b156ef` and installed +artifacts matching `loro-repo@0.20.0` and `@loro-dev/flock-wasm@0.4.3`. Synthetic Node +probes used in-memory replicas; they did not operate on product Session data. +The baseline probe checked rollback and repository publication against those pinned +versions; the durable contract now lives in the owning shared, browser, and CLI tests +listed below. + +| Boundary | Observed behavior | Consequence | +| ------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| Metadata storage | LoroRepo imports WASM Flock and stores fields at `m/docId/field` in one meta Flock. | Session documents are not separate metadata transaction stores. | +| WASM callback throws | Staged values disappear; no event or exported change remains. A peer's already imported update survives. | Local rollback can avoid authoring stale compensation. | +| Other Flock implementation | `@loro-dev/flock@4.4.4` retains data after the same throwing callback. | Pin and test the actual adapter, not the `txn` method name. | +| Direct raw metadata transaction | Raw values change while primed LoroRepo caches stay old and no repo patch is emitted. | Application hooks must not bypass the repository cache/event owner. | +| Remote repository import | One raw batch becomes per-document callbacks; a callback can read child archived and root active. | Atomic publication must include repository and consumer projections. | +| Concurrent raw transactions | Independent per-key clocks can converge to a mixed root/child tuple. | A local multi-key transaction is not whole-operation conflict resolution. | +| Durability | `upsertDocMeta` does not await persistence; `persistMetaNow` is separate. | Acceptance, local durability, and remote acknowledgement need distinct results. | + +The WASM package's shipped comments warn that data does not roll back, contradicting +the tested binary. Treat the observed behavior as version-specific evidence and keep +a dependency characterization gate. Importing into an active WASM transaction can +auto-commit it; transaction callbacks must contain no import or async work. + +The frontend explicitly disables metadata auto-debounce in +[`create-workspace-runtime.ts`](../../../../packages/components/src/providers/create-workspace-runtime.ts). +That makes raw `txn` callable there, but does not repair the cache/event bypass. +The repository's existing [dependency patch](../../../../patches/loro-repo.patch) +only changes live-monitor startup, not transaction semantics. + +## Alternatives and limits + +Reordering compensation cannot determine ownership of current values. A local +operation-id comparison followed by a blind write cannot account for a remote edit +that has not arrived, and conditional per-target rollback still permits partial +completion. Neither alternative supplies the required operation boundary. + +A repository-owned multi-key batch is useful infrastructure, but alone does not +preserve a transaction through per-key CRDT conflict resolution. Use native atomic +staging where necessary; do not make a general batch API a prerequisite if writing +one complete lifecycle record supplies the smaller boundary. + +A background reconciler can rebuild derived state from durable operations. It cannot +infer whether `root active / child archived` is an intentional Tab action or failed +compensation from those flags alone. Existing worktree GC reconciles disk resources, +not lifecycle metadata, and remains responsible only for root-owned worktrees. + +The local OSS renderer and daemon are one coordinated distribution and enable the new +authority only when the runtime is local-only. Cloud and dual runtimes keep the legacy +path because a daemon capability cannot fence independently authoring old renderers. +The public repository does not contain all product clients or a workspace-wide writer +admission mechanism; product activation requires that external evidence and must not +use a weaker dual-write mode. + +## Relationship to earlier decisions + +This proposal retains the containment decision in +[Keep opened Sessions outside opener state cascades](../../implemented/bug-fix/2026-09-10-session-containment-lifecycle.md). +It proposes replacing the compensation decision in +[Make cold-start archive discovery and commit failure-safe](../../implemented/bug-fix/2026-09-13-session-archive-complete-metadata-query.md), +while preserving that change's repository discovery. The older implemented note +records its historical implementation; it is not approval of this replacement. + +## Verification and rollout + +The local implementation has deterministic parser/resolver tests, real browser +IndexedDB reload coverage, real SQLite close/reopen and multi-connection allocation, +and a two-LoroRepo test proving the first projected read sees every target together. +Renderer and CLI producer suites assert one lifecycle commit; the UI cache installs a +revision in one write. Resource tests hold an old runtime termination across a newer +restore and prove the replacement generation is not archived or assigned idle status. +The `LODY-SESSION-004` desktop journey injects publication failure after durable browser +admission, reloads, and verifies that the same operation is replayed for the root and +direct child while independently opened Sessions remain active. + +These checks authorize the local-only switch, not product activation. Cloud and dual +runtimes deliberately retain the legacy implementation until every independently +deployed writer can be admitted or rejected by a shared compatibility boundary. +Worktree cleanup and terminal disposal follow effective committed state and cannot make +lifecycle commits reversible. + +[#658](https://github.com/LodyAI/Lody/pull/658) is the affected implementation. +[#574](https://github.com/LodyAI/Lody/issues/574) remains open until the product +compatibility gate has evidence. Local documentation checks may report links into +uninitialized ACP submodules; those findings are separate from implementation +verification. diff --git a/.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.zh.md b/.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.zh.md new file mode 100644 index 000000000..8f81dbe91 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.zh.md @@ -0,0 +1,79 @@ +# 将 Session 生命周期操作作为一个持久化事实提交 + +Status: proposed +Translation: current + +契约:[Session 关系](../../../../specs/session-relations.md) + +[English](2026-09-13-session-lifecycle-commit.md) + +## 摘要 + +遗留产品拓扑通过恢复先前读取的快照来补偿失败的元数据写入,这可能覆盖合法的并发写入,也可能自身只留下部分目标的变更。本地 OSS 拓扑现在会把每次归档或恢复记录为一个不可变操作,并通过共享的仓库投影推导有效的 Session 生命周期状态。操作是冲突解决、持久化和发布的单位;资源清理跟随所得状态。真实依赖探针确认本地 WASM 的回滚行为,真实 IndexedDB、SQLite 与 LoroRepo 测试覆盖了替代边界。产品混合客户端仍没有准入机制,因此更广泛的发布仍处于提案状态,#574 对该拓扑也尚未完成。 + +## 决策与范围 + +保留基于仓库的选定 Session 及其直接 `parentSessionId` 子级发现、明确的工作区所有权和提交后的终端清理。以仓库生命周期命令替代 `writeArchiveStateFailureSafe`、依赖顺序的写入、`attemptedTargets`、旧值补偿和回滚错误聚合。归档和恢复一起迁移,因为二者写入同一个权威来源。 + +一个操作冻结其目标 id、期望的归档状态、稳定身份和排序信息。其载荷在重试间不可变。共享解析器按完整操作排序,并发布一个有效元数据修订;它绝不为每个目标持久化一个独立的权威标志。独立 Tab 操作仍然有效,并使用相同的操作模型及单元素目标集合。执行状态仍由运行时拥有,绝不从生命周期快照恢复。 + +每个目标采用覆盖它的最高顺序操作。冻结集合相同的根操作一起选择同一胜者;集合不同时,较新操作未包含的目标保留此前结果。这是确定的操作优先级,不承诺所有子级始终与根同状态。 + +持久化准入包含崩溃安全的本地顺序分配,并在接受新命令前恢复已接纳但尚未发布的记录。发布和恢复允许重复交付同一个操作或修订;订阅者与资源副作用采用幂等处理,而不是声称通知恰好一次。资源处理还必须避免销毁恢复后产生的新 runtime 代次。 + +这是生命周期专用协议,不是通用 saga、命令队列或分布式数据库事务框架。兼容客户端仍针对自己的仓库本地写入。公共桌面端不引入 daemon 代理写入者、认证云端要求或托管实现。 + +冻结的 v1 wire 与准入布局如下: + +| 边界 | v1 契约 | +| --- | --- | +| 同步记录 | 每个不可变操作以一条规范 JSON 字符串存储在元数据文档 `_lody/session-lifecycle-operations/v1` 的 `operation:` 字段。 | +| 排序 | 规范非负十进制 Lamport counter,随后按 `actorId` 与 `operationId` 的 UTF-8 字节序决胜。 | +| 浏览器准入 | IndexedDB `lody-session-lifecycle-v1:`,包含 `admissions` 与 `state` object store。 | +| CLI 准入 | 工作区 Loro 存储目录中的专用 `session-lifecycle.sqlite3`,包含操作表与 high-water 表。 | +| 结果 | 持久 receipt 区分 `published` 与 `pending`;无法确认的存储结果携带同一个可查询 operation id。 | +| 迁移 | 已归档旧行生成确定性的 counter-zero `baseline:v1:` 操作;active 行继续使用默认 baseline。 | + +准入与本地 high-water 分配位于同一个存储事务。owner 会先安装完整 resolver revision,再通知逐 Session reader;启动时重放未发布准入,拒绝相同 operation id 的冲突 payload,并且不清理 v1 历史。本地启用后会拒绝直接写入遗留 `isArchived`,只有尚无 lifecycle winner 的 Session 可执行初始 `false` 写入。 + +## 依赖证据 + +检查使用了 Lody commit `54623883be77bd17f9dab18ef5a60cc2a9b156ef`,以及匹配 `loro-repo@0.20.0` 和 `@loro-dev/flock-wasm@0.4.3` 的已安装产物。使用的合成 Node 探针基于内存副本;没有操作产品 Session 数据。 + +基线探针针对这些固定版本检查了回滚与仓库发布行为;持久性契约现在由下文列出的 shared、browser 与 CLI owning tests 维护。 + +| 边界 | 观察到的行为 | 后果 | +| ------------------ | ----------------------------------------------------------------------------- | --------------------------------------------------- | +| 元数据存储 | LoroRepo 导入 WASM Flock,并在一个 meta Flock 中按 `m/docId/field` 存储字段。 | Session 文档不是独立的元数据事务存储。 | +| WASM 回调抛错 | 暂存值消失;没有事件或导出的变更留下。对端已导入的更新仍保留。 | 本地回滚可以避免写入过时的补偿。 | +| 其他 Flock 实现 | `@loro-dev/flock@4.4.4` 在相同的抛错回调后仍保留数据。 | 应测试并固定实际 adapter,而不是依赖 `txn` 方法名。 | +| 直接原始元数据事务 | 原始值发生变化,但预热的 LoroRepo 缓存仍旧且没有 repo patch 发出。 | 应用钩子不得绕过仓库缓存/事件所有者。 | +| 远程仓库导入 | 一个原始批次变成逐文档回调;回调可以读到 child archived 和 root active。 | 原子发布必须同时覆盖仓库和消费者投影。 | +| 并发原始事务 | 独立的逐键时钟可以收敛到混合的 root/child 元组。 | 本地多键事务不是完整操作的冲突解决。 | +| 持久化 | `upsertDocMeta` 不等待持久化;`persistMetaNow` 是分开的。 | 接受、本地持久化和远程确认需要分开的结果。 | + +WASM 包随附的注释警告数据不会回滚,这与测试过的二进制相矛盾。应将观察结果视为特定版本的证据,并保留依赖特征门槛。导入活动中的 WASM 事务可能自动提交它;事务回调不得包含导入或异步工作。 + +前端明确在 [`create-workspace-runtime.ts`](../../../../packages/components/src/providers/create-workspace-runtime.ts) 中禁用元数据自动防抖。这使原始 `txn` 可以在那里调用,但不会修复缓存/事件绕过。仓库现有的[依赖补丁](../../../../patches/loro-repo.patch)只修改 live-monitor 启动,不修改事务语义。 + +## 替代方案与限制 + +重新排序补偿无法确定当前值的所有权。在尚未到达的远程编辑存在时,本地操作 id 比较后盲写无法将其纳入考量;按目标条件回滚仍允许部分完成。两种替代方案都无法提供所需的操作边界。 + +仓库拥有的多键批处理是有用的基础设施,但单独使用无法在逐键 CRDT 冲突解决中保留事务。必要时使用原生原子暂存;如果写入一个完整的生命周期记录已经提供更小的边界,就不要让通用批处理 API 成为前置条件。 + +后台协调器可以从持久化操作重建派生状态。它无法仅从标志判断 `root active / child archived` 是有意的 Tab 操作还是失败的补偿。现有 worktree GC 协调的是磁盘资源,而不是生命周期元数据,并且仍只负责根拥有的 worktree。 + +本地 OSS renderer 与 daemon 属于同一个协调发布物,只在 runtime 为纯本地拓扑时启用新权威。cloud 与 dual runtime 保留遗留路径,因为 daemon capability 无法隔离独立写入旧 renderer 的行为。公共仓库不包含所有产品客户端或工作区级的写入者准入机制;产品启用需要外部证据,也不得用较弱的双写模式替代。 + +## 与早期决策的关系 + +本提案保留[将通过 opener 打开的 Session 排除在状态级联之外](../../implemented/bug-fix/2026-09-10-session-containment-lifecycle.md)中的包含关系决策。它提议替换[使冷启动归档发现和提交具备失败安全性](../../implemented/bug-fix/2026-09-13-session-archive-complete-metadata-query.md)中的补偿决策,同时保留该变更的仓库发现。较早的已实现 note 记录其历史实现;它不构成对本替代方案的批准。 + +## 验证与发布 + +本地实现已有确定性的 parser/resolver 测试、真实浏览器 IndexedDB reload、真实 SQLite 关闭重开与多连接分配,以及双 LoroRepo 测试,证明首次投影读取会同时看到所有目标。renderer 和 CLI producer suite 断言只提交一个 lifecycle 操作;UI cache 在一次写入中安装 revision。资源测试让旧 runtime 的终止跨越一次较新的 restore,并证明替代代次不会被归档或写成 idle。`LODY-SESSION-004` 桌面 journey 会在浏览器完成持久准入后注入发布失败,随后 reload,并验证同一个操作对根与直接 child 完成重放,同时独立 opened Session 保持 active。 + +这些检查只允许纯本地切换,不允许产品启用。cloud 与 dual runtime 会继续使用遗留实现,直到共享兼容性边界能够准入或拒绝每个独立发布的 writer。Worktree 清理与终端处置跟随有效提交状态,不能让生命周期提交变得可逆。 + +[#658](https://github.com/LodyAI/Lody/pull/658) 是受影响的实现。在产品兼容性门槛获得证据前,[#574](https://github.com/LodyAI/Lody/issues/574) 仍保持开放。本地文档检查可能报告指向未初始化 ACP 子模块的链接;这些发现与实现验证无关。 diff --git a/apps/cli/src/commands/session.test.ts b/apps/cli/src/commands/session.test.ts index 658c4f1e5..44f6b1bd6 100644 --- a/apps/cli/src/commands/session.test.ts +++ b/apps/cli/src/commands/session.test.ts @@ -20,9 +20,11 @@ import { createLocalProjectBranchSelector, normalizeLocalProjectRootPath, } from '@lody/shared/node/local-project'; +import type { LoroDocumentManager } from '../lib/loro/doc'; import { applyAgentRunConfigSelection, + applySessionLifecycleState, assertSupportedParentDepth, confirmDispatchSyncedBestEffort, buildSessionArchiveMetaPatch, @@ -992,6 +994,27 @@ describe('session command helpers', () => { }); }); + it('commits one lifecycle operation without authoring legacy flags or runtime status', async () => { + const root = 'lifecycle-root' as SessionId; + const child = 'lifecycle-child' as SessionId; + const commit = vi.fn(async () => undefined); + const upsertDocMeta = vi.fn(async () => undefined); + const manager = { + sessionLifecycle: { commit }, + repo: { upsertDocMeta }, + } as unknown as LoroDocumentManager; + + await applySessionLifecycleState(manager, root, [child], 'archived', 'stable-operation'); + + expect(commit).toHaveBeenCalledWith({ + operationId: 'stable-operation', + subjectId: root, + targetIds: [root, child], + state: 'archived', + }); + expect(upsertDocMeta).not.toHaveBeenCalled(); + }); + it('matches local project selectors against normalized paths', () => { const project = { id: 'local-project-1', diff --git a/apps/cli/src/commands/session.ts b/apps/cli/src/commands/session.ts index c1ac9cb68..c29119654 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -960,7 +960,7 @@ export async function listChildSessionIds( async function applySessionAndChildren( sessionId: SessionId, - childSessionIds: SessionId[], + childSessionIds: readonly SessionId[], apply: (sessionId: SessionId) => Promise ): Promise { await Promise.all([sessionId, ...childSessionIds].map(apply)); @@ -2874,6 +2874,30 @@ export function buildSessionRestoreMetaPatch(): Partial { }; } +export async function applySessionLifecycleState( + manager: LoroDocumentManager, + sessionId: SessionId, + childSessionIds: readonly SessionId[], + state: 'archived' | 'active', + operationId: string +): Promise { + if (manager.sessionLifecycle) { + await manager.sessionLifecycle.commit({ + operationId, + subjectId: sessionId, + targetIds: [sessionId, ...childSessionIds], + state, + }); + return; + } + await applySessionAndChildren(sessionId, childSessionIds, (id) => + manager.repo.upsertDocMeta( + getSessionRoomId(id), + state === 'archived' ? buildSessionArchiveMetaPatch() : buildSessionRestoreMetaPatch() + ) + ); +} + export async function createSessionResult( auth: AuthContext, workspace: WorkspaceSummary, @@ -4350,9 +4374,7 @@ const sessionArchiveCommand = new Command('archive') const childSessionIds = await listChildSessionIds(manager, sessionId); // The archived state is the whole request: the owning machine observes // it, releases the runtime, and reconciles the worktree directory. - await applySessionAndChildren(sessionId, childSessionIds, (id) => - manager.repo.upsertDocMeta(getSessionRoomId(id), buildSessionArchiveMetaPatch()) - ); + await applySessionLifecycleState(manager, sessionId, childSessionIds, 'archived', uuidV4()); await ensureWorkspaceMetaSynced(manager, `session.archive:${sessionId}`); if (options.json) { @@ -4387,9 +4409,7 @@ const sessionRestoreCommand = new Command('restore') throw new Error(`Session ${sessionId} is not archived.`); } const childSessionIds = await listChildSessionIds(manager, sessionId); - await applySessionAndChildren(sessionId, childSessionIds, (id) => - manager.repo.upsertDocMeta(getSessionRoomId(id), buildSessionRestoreMetaPatch()) - ); + await applySessionLifecycleState(manager, sessionId, childSessionIds, 'active', uuidV4()); await ensureWorkspaceMetaSynced(manager, `session.restore:${sessionId}`); if (options.json) { diff --git a/apps/cli/src/lib/README.md b/apps/cli/src/lib/README.md index 9b44ad4ce..de3fc08dc 100644 --- a/apps/cli/src/lib/README.md +++ b/apps/cli/src/lib/README.md @@ -30,6 +30,11 @@ subdirectory; this file is the navigation index. Cross-module explanations live ## Sessions, files, and attachments +- `loro/session-lifecycle-persistence.ts` — local-only SQLite admission journal for + immutable archive/restore operations. `LoroDocumentManager` replays pending records + and exposes their effective repository projection; cloud-capable runtimes retain the + legacy path until the product compatibility gate is available. Contract and layout: + [Session lifecycle decision](../../../../.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md). - `session-image-download.ts` — CLI-side prompt image download through the injected attachment capability, including short retries before converting bytes to ACP image blocks. diff --git a/apps/cli/src/lib/lody.ts b/apps/cli/src/lib/lody.ts index aa93e43d4..a12497f95 100644 --- a/apps/cli/src/lib/lody.ts +++ b/apps/cli/src/lib/lody.ts @@ -73,6 +73,7 @@ export class Lody { { workspaceId: options.workspaceId }, async () => await LoroDocumentManager.create(options.workspaceId, options.userId, options.logger, { + enableSessionLifecycle: options.cloudPort.kind === 'local', streamsTokens: options.cloudPort.streamsTokens, cloudBilling: options.cloudPort.billing, }) diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index 68168f5d0..d731b29f8 100644 --- a/apps/cli/src/lib/loro/doc.ts +++ b/apps/cli/src/lib/loro/doc.ts @@ -63,6 +63,10 @@ import { isSensitiveAcpConfigOptionId, BuiltinRuntimeOverridesSchema, CustomAcpLaunchSpecSchema, + createLoroMetaSessionLifecyclePublisher, + createSessionLifecycleBaselineOperation, + installSessionLifecycleRepoProjection, + SessionLifecycleRepository, } from '@lody/shared'; import { LocalLoroDataPlaneServer } from '@lody/shared/local-loro-data-plane-server'; import { createLocalLoroDataPlaneScheduler } from '@lody/shared/local-loro-data-plane-scheduler'; @@ -100,6 +104,7 @@ import { getProxyForUrl } from 'proxy-from-env'; import { HttpsProxyAgent } from 'https-proxy-agent'; import type { RateLimit } from 'acp-extension-core'; import { createCliSqliteRepoStore } from './sqlite-repo-store'; +import { createSqliteSessionLifecycleAdmissionStore } from './session-lifecycle-persistence'; import { streamsRoomBinding, type StreamsRoomBinding } from './streams-room-binding'; import { formatErrorMessage } from '@/utils/format-error'; import { @@ -314,6 +319,7 @@ export interface LoroDocumentManagerOptions { remoteStreamsAttached?: boolean; streamsTokens?: CloudStreamsTokenPort | null; cloudBilling?: CloudBillingPort | null; + sessionLifecycle?: SessionLifecycleRepository | null; } export type LoroRepoPersistReason = @@ -354,6 +360,7 @@ export class LoroDocumentManager { private remoteTransportOpQueue: Promise = Promise.resolve(); private readonly streamsTokens: CloudStreamsTokenPort | null; public readonly cloudBilling: CloudBillingPort | null; + public readonly sessionLifecycle: SessionLifecycleRepository | null; static async create( workspaceId: WorkspaceId, @@ -361,6 +368,7 @@ export class LoroDocumentManager { logger: Logger, options: { attachRemoteOnCreate?: boolean; + enableSessionLifecycle?: boolean; streamsTokens?: CloudStreamsTokenPort | null; cloudBilling?: CloudBillingPort | null; } = {} @@ -391,6 +399,7 @@ export class LoroDocumentManager { let repo: LoroRepo | null = null; let manager: LoroDocumentManager | null = null; + let sessionLifecycle: SessionLifecycleRepository | null = null; try { const createRepoStartMs = Date.now(); repo = await traceAsync( @@ -410,6 +419,37 @@ export class LoroDocumentManager { ); const createdRepo = repo; logger.debug(`[${workspaceId}] LoroRepo created in ${Date.now() - createRepoStartMs}ms`); + // The public local-only topology upgrades its bundled renderer and daemon + // together. Cloud-capable workspaces remain on the legacy representation + // until independently deployed writers can be fenced at admission. + if (options.enableSessionLifecycle === true) { + if (options.streamsTokens) { + throw new Error('Session lifecycle operations require the local-only topology'); + } + const rawSessionEntries = (await createdRepo.listDoc()).filter( + (entry) => getSessionIdFromRoomId(entry.docId) && !isLoroRepoDocDeleted(entry) + ); + sessionLifecycle = new SessionLifecycleRepository({ + actorId: `cli:${userId}`, + store: await createSqliteSessionLifecycleAdmissionStore({ workspaceId }), + publisher: createLoroMetaSessionLifecyclePublisher(createdRepo), + }); + await sessionLifecycle.initialize({ + baselines: rawSessionEntries.flatMap((entry) => { + const sessionId = getSessionIdFromRoomId(entry.docId); + return sessionId && entry.meta.isArchived === true + ? [createSessionLifecycleBaselineOperation(sessionId)] + : []; + }), + }); + installSessionLifecycleRepoProjection({ + repo: createdRepo, + repository: sessionLifecycle, + getSessionId: getSessionIdFromRoomId, + getSessionDocId: getSessionRoomId, + rejectLegacyWrites: true, + }); + } // Presence is produced locally regardless of remote sync so local-first // renderers get machine/session liveness over the data plane; the cloud // Streams sink is attached later by the remote bridge. @@ -455,6 +495,7 @@ export class LoroDocumentManager { remoteStreamsAttached: false, streamsTokens: options.streamsTokens ?? null, cloudBilling: options.cloudBilling ?? null, + sessionLifecycle, }); } catch (error) { try { @@ -470,6 +511,7 @@ export class LoroDocumentManager { )}` ); } + await sessionLifecycle?.dispose().catch(() => undefined); throw error; } @@ -515,6 +557,7 @@ export class LoroDocumentManager { this.remoteStreamsAttached = options.remoteStreamsAttached ?? false; this.streamsTokens = options.streamsTokens ?? null; this.cloudBilling = options.cloudBilling ?? null; + this.sessionLifecycle = options.sessionLifecycle ?? null; this.remoteStreamsGeneration = this.remoteStreamsAttached ? 1 : 0; this.presenceRuntime = options.presenceRuntime ?? null; this.machineMonitorRuntime = options.machineMonitorRuntime ?? null; @@ -1656,6 +1699,10 @@ export class LoroDocumentManager { this.remoteStreamsStatusUnsubscribe = null; // A coalesced flush may still be waiting out its debounce window. await this.remoteSyncPersist.flushNow(); + if (this.sessionLifecycle) { + await this.sessionLifecycle.flushPending().catch(() => undefined); + await this.sessionLifecycle.dispose(); + } await this.destroyRepo({ fast: options.fast }); } diff --git a/apps/cli/src/lib/loro/session-lifecycle-persistence.test.ts b/apps/cli/src/lib/loro/session-lifecycle-persistence.test.ts new file mode 100644 index 000000000..de0b6b590 --- /dev/null +++ b/apps/cli/src/lib/loro/session-lifecycle-persistence.test.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { SessionId, WorkspaceId } from '@lody/shared'; +import { createSqliteSessionLifecycleAdmissionStore } from './session-lifecycle-persistence'; + +const dirs: string[] = []; +const id = (value: string): SessionId => value as SessionId; + +async function createStore(faults?: { beforeWrite?: () => void; afterCommit?: () => void }) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lody-session-lifecycle-')); + dirs.push(dir); + const dbPath = path.join(dir, 'lifecycle.sqlite3'); + return { + dbPath, + store: await createSqliteSessionLifecycleAdmissionStore({ + workspaceId: 'workspace' as WorkspaceId, + dbPath, + faults, + }), + }; +} + +const draft = (operationId: string) => ({ + operationId, + subjectId: id('root'), + targetIds: [id('root'), id('child')], + state: 'archived' as const, +}); + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe('SQLite Session lifecycle admission store', () => { + it('persists one immutable admission and recovers it after close/reopen', async () => { + const { dbPath, store } = await createStore(); + const admitted = await store.admit(draft('op-1'), 'actor', '7'); + await store.close?.(); + const reopened = await createSqliteSessionLifecycleAdmissionStore({ + workspaceId: 'workspace' as WorkspaceId, + dbPath, + }); + expect(await reopened.get('op-1')).toEqual(admitted); + await reopened.close?.(); + }); + + it('serializes allocation across connections and advances above unpublished high-water', async () => { + const { dbPath, store } = await createStore(); + const secondStore = await createSqliteSessionLifecycleAdmissionStore({ + workspaceId: 'workspace' as WorkspaceId, + dbPath, + }); + await store.observeCounter('20'); + const [first, second] = await Promise.all([ + store.admit(draft('first'), 'actor', '0'), + secondStore.admit(draft('second'), 'actor', '0'), + ]); + expect(new Set([first.operation.order.counter, second.operation.order.counter])).toEqual( + new Set(['21', '22']) + ); + await secondStore.close?.(); + await store.close?.(); + + const reopened = await createSqliteSessionLifecycleAdmissionStore({ + workspaceId: 'workspace' as WorkspaceId, + dbPath, + }); + const third = await reopened.admit(draft('third'), 'actor', '0'); + expect(third.operation.order.counter).toBe('23'); + await reopened.close?.(); + }); + + it('distinguishes failure before a write from failure after durable commit', async () => { + const before = await createStore({ beforeWrite: () => { throw new Error('before'); } }); + await expect(before.store.admit(draft('before'), 'actor', '0')).rejects.toThrow('before'); + expect(await before.store.get('before')).toBeUndefined(); + await before.store.close?.(); + + const after = await createStore({ afterCommit: () => { throw new Error('after'); } }); + await expect(after.store.admit(draft('after'), 'actor', '0')).rejects.toThrow('after'); + expect((await after.store.get('after'))?.operation.operationId).toBe('after'); + await after.store.close?.(); + }); + + it('marks publication without changing operation identity or order', async () => { + const { store } = await createStore(); + const admitted = await store.admit(draft('op-1'), 'actor', '0'); + await store.markPublished('op-1'); + expect(await store.get('op-1')).toEqual({ ...admitted, published: true }); + await store.close?.(); + }); +}); diff --git a/apps/cli/src/lib/loro/session-lifecycle-persistence.ts b/apps/cli/src/lib/loro/session-lifecycle-persistence.ts new file mode 100644 index 000000000..2195e6f00 --- /dev/null +++ b/apps/cli/src/lib/loro/session-lifecycle-persistence.ts @@ -0,0 +1,178 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { + canonicalizeSessionLifecycleOperation, + encodeSessionLifecycleOperation, + parseSessionLifecycleOperation, + SessionLifecycleAdmissionRejectedError, + SessionLifecycleOperationConflictError, + type SessionLifecycleAdmission, + type SessionLifecycleAdmissionStore, + type SessionLifecycleOperationDraft, + type WorkspaceId, +} from '@lody/shared'; +import { getLoroRepoStorageBaseDir } from './sqlite-repo-store'; + +type AdmissionRow = { + operation_json: string; + published: number; +}; + +type StateRow = { value: string }; + +type AdmissionFaults = { + beforeWrite?: () => void; + afterCommit?: () => void; +}; + +export const getSessionLifecycleSqlitePath = (workspaceId: WorkspaceId): string => + path.join(getLoroRepoStorageBaseDir(workspaceId), 'session-lifecycle.sqlite3'); + +function decodeAdmission(row: AdmissionRow): SessionLifecycleAdmission { + return { + operation: parseSessionLifecycleOperation(JSON.parse(row.operation_json)), + published: row.published === 1, + }; +} + +function draftMatchesAdmission( + draft: SessionLifecycleOperationDraft, + admission: SessionLifecycleAdmission +): boolean { + return ( + encodeSessionLifecycleOperation(admission.operation) === + encodeSessionLifecycleOperation( + canonicalizeSessionLifecycleOperation({ + version: 1, + ...draft, + order: admission.operation.order, + }) + ) + ); +} + +export async function createSqliteSessionLifecycleAdmissionStore(options: { + workspaceId: WorkspaceId; + dbPath?: string; + faults?: AdmissionFaults; +}): Promise { + const dbPath = options.dbPath ?? getSessionLifecycleSqlitePath(options.workspaceId); + await fs.mkdir(path.dirname(dbPath), { recursive: true }); + const database = new Database(dbPath); + database.pragma('busy_timeout = 5000'); + database.pragma('journal_mode = WAL'); + database.pragma('synchronous = FULL'); + database.exec(` + CREATE TABLE IF NOT EXISTS session_lifecycle_operations ( + operation_id TEXT PRIMARY KEY, + operation_json TEXT NOT NULL, + published INTEGER NOT NULL CHECK (published IN (0, 1)) + ); + CREATE TABLE IF NOT EXISTS session_lifecycle_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `); + + const selectOne = database.prepare<[string], AdmissionRow>( + 'SELECT operation_json, published FROM session_lifecycle_operations WHERE operation_id = ?' + ); + const selectAll = database.prepare<[], AdmissionRow>( + 'SELECT operation_json, published FROM session_lifecycle_operations ORDER BY operation_id' + ); + const selectState = database.prepare<[string], StateRow>( + 'SELECT value FROM session_lifecycle_state WHERE key = ?' + ); + const insertOperation = database.prepare<[string, string]>( + 'INSERT INTO session_lifecycle_operations(operation_id, operation_json, published) VALUES (?, ?, 0)' + ); + const upsertState = database.prepare<[string, string]>( + 'INSERT INTO session_lifecycle_state(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ); + const markPublished = database.prepare<[string]>( + 'UPDATE session_lifecycle_operations SET published = 1 WHERE operation_id = ?' + ); + + const admit = database.transaction( + ( + draft: SessionLifecycleOperationDraft, + actorId: string, + observedCounter: string + ): SessionLifecycleAdmission => { + const existingRow = selectOne.get(draft.operationId); + if (existingRow) { + const existing = decodeAdmission(existingRow); + if (!draftMatchesAdmission(draft, existing)) { + throw new SessionLifecycleOperationConflictError(draft.operationId); + } + return existing; + } + const localCounter = BigInt(selectState.get('highWater')?.value ?? '0'); + const observed = BigInt(observedCounter); + const counter = (localCounter > observed ? localCounter : observed) + 1n; + const operation = canonicalizeSessionLifecycleOperation({ + version: 1, + ...draft, + order: { counter: counter.toString(10), actorId }, + }); + insertOperation.run(operation.operationId, encodeSessionLifecycleOperation(operation)); + upsertState.run('highWater', counter.toString(10)); + return { operation, published: false }; + } + ); + const observeCounter = database.transaction((counter: string) => { + const current = BigInt(selectState.get('highWater')?.value ?? '0'); + const observed = BigInt(counter); + if (observed > current) upsertState.run('highWater', counter); + }); + const seed = database.transaction((operationJson: string, operationId: string) => { + const existingRow = selectOne.get(operationId); + if (existingRow) { + const existing = decodeAdmission(existingRow); + if (encodeSessionLifecycleOperation(existing.operation) !== operationJson) { + throw new SessionLifecycleOperationConflictError(operationId); + } + return existing; + } + insertOperation.run(operationId, operationJson); + return { operation: parseSessionLifecycleOperation(JSON.parse(operationJson)), published: false }; + }); + + return { + async list() { + return selectAll.all().map(decodeAdmission); + }, + async get(operationId) { + const row = selectOne.get(operationId); + return row ? decodeAdmission(row) : undefined; + }, + async admit(draft, actorId, observedCounter) { + let admission: SessionLifecycleAdmission; + try { + options.faults?.beforeWrite?.(); + admission = admit.immediate(draft, actorId, observedCounter); + } catch (cause) { + if (cause instanceof SessionLifecycleOperationConflictError) throw cause; + throw new SessionLifecycleAdmissionRejectedError( + cause instanceof Error ? cause.message : 'SQLite lifecycle admission rejected', + { cause } + ); + } + options.faults?.afterCommit?.(); + return admission; + }, + async observeCounter(counter) { + observeCounter.immediate(counter); + }, + async seed(operation) { + return seed.immediate(encodeSessionLifecycleOperation(operation), operation.operationId); + }, + async markPublished(operationId) { + markPublished.run(operationId); + }, + async close() { + database.close(); + }, + }; +} diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 2f37f7807..6822f19d1 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -4000,15 +4000,34 @@ export class MessageHandler { if (!(await this.isSessionOwnedByThisMachine(sessionId))) { return; } + const lifecycleOperationId = this.workspaceDocument.sessionLifecycle + ?.getRevision() + .bySessionId.get(sessionId)?.operationId; + if (!(await this.isArchiveObservationCurrent(sessionId, lifecycleOperationId))) { + return; + } this.archiveInFlight.add(sessionId); try { - await this.releaseArchivedSessionRuntime(sessionId); + await this.releaseArchivedSessionRuntime(sessionId, { lifecycleOperationId }); } catch (error) { this.logger.error( `[${sessionId}] Failed to release archived session runtime: ${formatErrorMessage(error)}` ); } finally { + const nextWinner = this.workspaceDocument.sessionLifecycle + ?.getRevision() + .bySessionId.get(sessionId); + const shouldReplayNewerArchive = + lifecycleOperationId !== undefined && + nextWinner?.state === 'archived' && + nextWinner.operationId !== lifecycleOperationId; this.archiveInFlight.delete(sessionId); + if (shouldReplayNewerArchive) { + // A restore followed by a newer archive can arrive while the prior + // generation is still terminating. Its watch event was coalesced by + // archiveInFlight, so replay the latest winner after releasing the gate. + void this.handleSessionArchived(sessionId); + } } void this.worktreeGc.schedule(); } @@ -4021,32 +4040,75 @@ export class MessageHandler { */ private async releaseArchivedSessionRuntime( sessionId: SessionId, - options: { writeIdleStatus?: boolean } = {} + options: { + writeIdleStatus?: boolean; + lifecycleOperationId?: string; + expectedState?: 'archived' | 'deleted'; + } = {} ): Promise { this.logger.debug(`[${sessionId}] Releasing archived session runtime`); + const observationIsCurrent = async (): Promise => + options.expectedState === 'deleted' + ? await this.isDeletionObservationCurrent(sessionId) + : await this.isArchiveObservationCurrent(sessionId, options.lifecycleOperationId); + if (!(await observationIsCurrent())) return; + const capturedSession = this.sessionManager.getSession(sessionId); + this.clearSessionActivePresence(sessionId); this.closeSessionTerminals?.(sessionId); await this.finalizeACPState(sessionId); + if (!(await observationIsCurrent())) return; await this.previewService.closeSessionPreviewForCleanup(sessionId, 'Session archived'); + if (!(await observationIsCurrent())) return; await this.terminateActiveChildSessions(sessionId, 'Parent session archived'); + if (!(await observationIsCurrent())) return; - if (this.sessionManager.hasSession(sessionId)) { + if (capturedSession) { this.logger.debug(`[${sessionId}] Terminating active session`); - await this.sessionManager.terminateSession(sessionId, true); - if (options.writeIdleStatus !== false) { + await capturedSession.terminate(true); + const currentSessionAfterTerminate = this.sessionManager.getSession(sessionId); + if ( + options.writeIdleStatus !== false && + (await observationIsCurrent()) && + (currentSessionAfterTerminate === null || currentSessionAfterTerminate === capturedSession) + ) { await this.workspaceDocument.repo.upsertDocMeta(getSessionRoomId(sessionId), { status: SessionStatusFactory.idle(), } as Partial); } } + if (!(await observationIsCurrent())) return; + const currentSession = this.sessionManager.getSession(sessionId); + if (currentSession && currentSession !== capturedSession) return; await this.sessionManager.archiveSession(sessionId); this.store.get(sessionId).logger = null; this.logger.debug(`[${sessionId}] Archived session runtime released`); } + private async isArchiveObservationCurrent( + sessionId: SessionId, + lifecycleOperationId?: string + ): Promise { + const lifecycle = this.workspaceDocument.sessionLifecycle; + if (lifecycle) { + const winner = lifecycle.getRevision().bySessionId.get(sessionId); + return ( + winner?.state === 'archived' && + (lifecycleOperationId === undefined || winner.operationId === lifecycleOperationId) + ); + } + const snapshot = await this.workspaceDocument.repo.getDocMeta(getSessionRoomId(sessionId)); + return (snapshot?.meta as SessionMeta | undefined)?.isArchived === true; + } + + private async isDeletionObservationCurrent(sessionId: SessionId): Promise { + const snapshot = await this.workspaceDocument.repo.getDocMeta(getSessionRoomId(sessionId)); + return snapshot?.deleted === true; + } + private async handleSessionDeleted(sessionId: SessionId): Promise { if (this.deletedSessionIds.has(sessionId)) { return; @@ -4057,7 +4119,10 @@ export class MessageHandler { this.logger.debug(`[${sessionId}] Session doc deleted; releasing runtime`); try { // Never write into a deleted doc: the idle-status patch stays archive-only. - await this.releaseArchivedSessionRuntime(sessionId, { writeIdleStatus: false }); + await this.releaseArchivedSessionRuntime(sessionId, { + writeIdleStatus: false, + expectedState: 'deleted', + }); } catch (error) { this.logger.error( `[${sessionId}] Failed to release deleted session runtime: ${formatErrorMessage(error)}` @@ -4220,7 +4285,10 @@ export class MessageHandler { } } - private async archiveLocalProjectSessions(localProjectId: LocalProjectId): Promise { + private async archiveLocalProjectSessions( + localProjectId: LocalProjectId, + command: MachineDeleteLocalProjectCommand + ): Promise { const sessions = (await listAliveSessionMetas(this.workspaceDocument)).filter(({ meta }) => isSessionInLocalProjectRemovalScope(meta, { machineId: this.machineId, @@ -4229,28 +4297,68 @@ export class MessageHandler { ); if (sessions.length === 0) return; - const rootSessions = sessions.filter( - ({ meta }) => meta.isArchived !== true && !meta.parentSessionId - ); - const archivedRootSessionIds = new Set(rootSessions.map(({ meta }) => meta.id)); - for (const { roomId, meta } of rootSessions) { - await this.releaseArchivedSessionRuntime(meta.id); - await this.workspaceDocument.repo.upsertDocMeta(roomId, { - isArchived: true, - status: SessionStatusFactory.idle(), - } as Partial); - } - - for (const { roomId, meta } of sessions) { - if (archivedRootSessionIds.has(meta.id)) continue; - if (this.sessionManager.hasSession(meta.id)) { + if (this.workspaceDocument.sessionLifecycle) { + const handled = new Set(); + const roots = sessions.filter(({ meta }) => !meta.parentSessionId); + const groups = roots.map(({ meta: root }) => [ + root, + ...sessions + .map(({ meta }) => meta) + .filter((candidate) => candidate.parentSessionId === root.id), + ]); + for (const group of groups) { + if (group.every((meta) => meta.isArchived === true)) { + group.forEach((meta) => handled.add(meta.id)); + continue; + } + const root = group[0]; + if (!root) continue; + await this.workspaceDocument.sessionLifecycle.commit({ + operationId: `local-project-removal:${localProjectId}:${command.requestedAt}:${root.id}`, + subjectId: root.id, + targetIds: group.map((meta) => meta.id), + state: 'archived', + }); + for (const meta of group) { + handled.add(meta.id); + await this.handleSessionArchived(meta.id); + } + } + for (const { meta } of sessions) { + if (handled.has(meta.id) || meta.isArchived === true) continue; + await this.workspaceDocument.sessionLifecycle.commit({ + operationId: `local-project-removal:${localProjectId}:${command.requestedAt}:${meta.id}`, + subjectId: meta.id, + targetIds: [meta.id], + state: 'archived', + }); + await this.handleSessionArchived(meta.id); + } + } else { + const rootSessions = sessions.filter( + ({ meta }) => meta.isArchived !== true && !meta.parentSessionId + ); + const archivedRootSessionIds = new Set(rootSessions.map(({ meta }) => meta.id)); + for (const { roomId, meta } of rootSessions) { + await this.workspaceDocument.repo.upsertDocMeta(roomId, { + isArchived: true, + status: SessionStatusFactory.idle(), + } as Partial); await this.releaseArchivedSessionRuntime(meta.id); } - if (meta.isArchived === true) continue; - await this.workspaceDocument.repo.upsertDocMeta(roomId, { - isArchived: true, - status: SessionStatusFactory.idle(), - } as Partial); + + for (const { roomId, meta } of sessions) { + if (archivedRootSessionIds.has(meta.id)) continue; + if (meta.isArchived !== true) { + await this.workspaceDocument.repo.upsertDocMeta(roomId, { + isArchived: true, + status: SessionStatusFactory.idle(), + } as Partial); + } + if (this.sessionManager.hasSession(meta.id)) { + await this.releaseArchivedSessionRuntime(meta.id); + } + } } this.logger.debug( @@ -4281,7 +4389,7 @@ export class MessageHandler { return undefined; } - await this.archiveLocalProjectSessions(localProjectId); + await this.archiveLocalProjectSessions(localProjectId, command); let cleanupResult: MachineDeleteLocalProjectCommand['cleanupResult']; const originalRootPath = existingProject?.rootPath ?? command.originalRootPath; diff --git a/apps/cli/tests/local-platform-zero-cloud.test.ts b/apps/cli/tests/local-platform-zero-cloud.test.ts index 13d1bb868..1919666d1 100644 --- a/apps/cli/tests/local-platform-zero-cloud.test.ts +++ b/apps/cli/tests/local-platform-zero-cloud.test.ts @@ -101,12 +101,14 @@ describe('local platform zero-cloud integration', () => { identity.userId, logger, { + enableSessionLifecycle: true, streamsTokens: cloudPort.streamsTokens, cloudBilling: cloudPort.billing, }, ); try { expect(documentManager.isTransportConnected()).toBe(true); + expect(documentManager.sessionLifecycle).not.toBeNull(); await expect( cloudPort.access.verifyMachineAccess({ workspaceId: workspace.id as WorkspaceId, diff --git a/apps/cli/tests/loro-document-manager-create.test.ts b/apps/cli/tests/loro-document-manager-create.test.ts index 4b1d10761..dd4290b17 100644 --- a/apps/cli/tests/loro-document-manager-create.test.ts +++ b/apps/cli/tests/loro-document-manager-create.test.ts @@ -296,6 +296,22 @@ describe('LoroDocumentManager.create degraded startup behavior', () => { expect(repoDestroy).toHaveBeenCalledTimes(1); }); + it('rejects lifecycle activation when a cloud writer can participate', async () => { + const repoDestroy = vi.fn(async () => {}); + mocks.repoCreate.mockResolvedValueOnce({ destroy: repoDestroy }); + + await expect( + LoroDocumentManager.create( + 'workspace-incompatible-lifecycle' as WorkspaceId, + 'user-1', + createSilentLogger(), + { enableSessionLifecycle: true, streamsTokens: testStreamsTokens } + ) + ).rejects.toThrow('Session lifecycle operations require the local-only topology'); + + expect(repoDestroy).toHaveBeenCalledOnce(); + }); + it('continues in degraded mode when meta room sync times out', async () => { process.env.LODY_LORO_SYNC_META_TIMEOUT_MS = '1'; diff --git a/apps/cli/tests/message-handler-terminal-cleanup.test.ts b/apps/cli/tests/message-handler-terminal-cleanup.test.ts index 976e11f0b..12b783e62 100644 --- a/apps/cli/tests/message-handler-terminal-cleanup.test.ts +++ b/apps/cli/tests/message-handler-terminal-cleanup.test.ts @@ -71,6 +71,14 @@ function createHarness(options?: { includeLegacySessionArchiveRequest?: boolean; deletedSessionIds?: SessionId[]; localProjectRootPaths?: Record; + lifecycleWinner?: { operationId: string; state: 'archived' | 'active' }; + lifecycleCommit?: (draft: { + operationId: string; + subjectId: SessionId; + targetIds: readonly SessionId[]; + state: 'archived' | 'active'; + }) => Promise; + terminateSessionInstance?: (sessionId: SessionId) => Promise; }) { const sessionId = options?.sessionId ?? ('session-1' as SessionId); const childSessionIds = options?.childSessionIds ?? []; @@ -120,7 +128,7 @@ function createHarness(options?: { return { meta: localSessionMeta, deleted }; } if (roomId === sessionRoomId) { - return { meta: { isArchived: true, machineId } }; + return { meta: { isArchived: true, machineId }, deleted: true }; } if (roomId === machineRoomId) { return { @@ -167,22 +175,74 @@ function createHarness(options?: { deleteDoc: vi.fn(async () => {}), flush: vi.fn(async () => {}), }; + const lifecycleWinners = new Map< + SessionId, + { + operationId: string; + state: 'archived' | 'active'; + order: { counter: string; actorId: string }; + } + >(); + if (options?.lifecycleWinner) { + lifecycleWinners.set(sessionId, { + ...options.lifecycleWinner, + order: { counter: '1', actorId: 'test' }, + }); + } const workspaceDocument = { sessions: new Map(), repo, getOrCreateSessionDoc: vi.fn(async () => sessionDoc), isTransportConnected: vi.fn(() => true), markMachineFlockDocDirty: vi.fn(), + sessionLifecycle: + options?.lifecycleWinner || options?.lifecycleCommit + ? { + getRevision: () => ({ bySessionId: lifecycleWinners }), + commit: async (draft: { + operationId: string; + subjectId: SessionId; + targetIds: readonly SessionId[]; + state: 'archived' | 'active'; + }) => { + await options?.lifecycleCommit?.(draft); + for (const targetId of draft.targetIds) { + lifecycleWinners.set(targetId, { + operationId: draft.operationId, + state: draft.state, + order: { counter: '2', actorId: 'test' }, + }); + } + }, + } + : null, }; + const activeSessions = new Map }>(); + for (const id of activeSessionIds) { + const session = { + terminate: vi.fn(async () => { + await options?.terminateSessionInstance?.(id); + if (activeSessions.get(id) === session) { + activeSessionIds.delete(id); + activeSessions.delete(id); + } + }), + }; + activeSessions.set(id, session); + } const sessionManager = { on: vi.fn(), setRequestPermissionHandler: vi.fn(), getActiveChildSessionIds: vi.fn(() => childSessionIds), hasSession: vi.fn((id: SessionId) => activeSessionIds.has(id)), + getSession: vi.fn((id: SessionId) => activeSessions.get(id) ?? null), terminateSession: vi.fn(async (id: SessionId) => { activeSessionIds.delete(id); }), - archiveSession: vi.fn(async () => {}), + archiveSession: vi.fn(async (id: SessionId) => { + activeSessionIds.delete(id); + activeSessions.delete(id); + }), cleanUp: vi.fn(async () => {}), setSessionError: vi.fn(async () => {}), }; @@ -221,6 +281,16 @@ function createHarness(options?: { isSessionActive: (id: SessionId) => activeSessionIds.has(id), flockSet, flockCommit, + setLifecycleWinner: (winner: { operationId: string; state: 'archived' | 'active' }) => { + lifecycleWinners.set(sessionId, { + ...winner, + order: { counter: '3', actorId: 'test' }, + }); + }, + setActiveSession: (id: SessionId, session: { terminate: ReturnType }) => { + activeSessionIds.add(id); + activeSessions.set(id, session); + }, }; } @@ -264,6 +334,85 @@ describe('MessageHandler terminal cleanup', () => { expect(getSessionMeta(sessionId)).toMatchObject({ status: SessionStatusFactory.idle() }); }); + it('does not tear down a restored replacement runtime while old termination is pending', async () => { + const sessionId = 'session-runtime-generation' as SessionId; + let releaseTerminate!: () => void; + let signalTerminateStarted!: () => void; + const terminateStarted = new Promise((resolve) => { + signalTerminateStarted = resolve; + }); + const terminateGate = new Promise((resolve) => { + releaseTerminate = resolve; + }); + const harness = createHarness({ + sessionId, + activeSessionIds: [sessionId], + lifecycleWinner: { operationId: 'archive-1', state: 'archived' }, + terminateSessionInstance: async () => { + signalTerminateStarted(); + await terminateGate; + }, + }); + + const cleanup = harness.handler.handleSessionArchived(sessionId); + await terminateStarted; + const replacement = { terminate: vi.fn(async () => undefined) }; + harness.setLifecycleWinner({ operationId: 'restore-2', state: 'active' }); + harness.setActiveSession(sessionId, replacement); + releaseTerminate(); + await cleanup; + + expect(replacement.terminate).not.toHaveBeenCalled(); + expect(harness.sessionManager.archiveSession).not.toHaveBeenCalled(); + expect(harness.sessionManager.getSession(sessionId)).toBe(replacement); + expect(harness.repo.upsertDocMeta).not.toHaveBeenCalledWith( + getSessionRoomId(sessionId), + expect.objectContaining({ status: SessionStatusFactory.idle() }) + ); + }); + + it('replays a newer archive that arrives while an older generation is terminating', async () => { + const sessionId = 'session-runtime-rearchive' as SessionId; + let releaseTerminate!: () => void; + let signalTerminateStarted!: () => void; + let signalReplacementTerminated!: () => void; + const terminateStarted = new Promise((resolve) => { + signalTerminateStarted = resolve; + }); + const terminateGate = new Promise((resolve) => { + releaseTerminate = resolve; + }); + const replacementTerminated = new Promise((resolve) => { + signalReplacementTerminated = resolve; + }); + const harness = createHarness({ + sessionId, + activeSessionIds: [sessionId], + lifecycleWinner: { operationId: 'archive-1', state: 'archived' }, + terminateSessionInstance: async () => { + signalTerminateStarted(); + await terminateGate; + }, + }); + + const cleanup = harness.handler.handleSessionArchived(sessionId); + await terminateStarted; + const replacement = { + terminate: vi.fn(async () => { + signalReplacementTerminated(); + }), + }; + harness.setLifecycleWinner({ operationId: 'restore-2', state: 'active' }); + harness.setActiveSession(sessionId, replacement); + harness.setLifecycleWinner({ operationId: 'archive-3', state: 'archived' }); + await harness.handler.handleSessionArchived(sessionId); + releaseTerminate(); + await cleanup; + await replacementTerminated; + + expect(replacement.terminate).toHaveBeenCalledOnce(); + }); + it('removes the worktree of an archived local-project session and keeps its branch', async () => { const localProjectId = 'local-project-archive' as LocalProjectId; const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lody-archive-project-')); @@ -529,6 +678,71 @@ describe('MessageHandler terminal cleanup', () => { expect(machineFlockRows).not.toContainEqual(expect.objectContaining({ key: localProjectKey })); }); + it('uses one durable lifecycle operation for a local-project root and its direct child', async () => { + const localProjectId = 'local-project-lifecycle' as LocalProjectId; + const rootSessionId = 'session-lifecycle-root' as SessionId; + const childSessionId = 'session-lifecycle-child' as SessionId; + const project = { kind: 'local' as const, localProjectId }; + const commit = vi.fn(async () => undefined); + const localProjectKey = machineFlockKeys.localProject(localProjectId); + const harness = createHarness({ + sessionId: rootSessionId, + childSessionIds: [childSessionId], + lifecycleCommit: commit, + sessionMetas: [ + { + id: rootSessionId, + machineId: 'machine-1', + createdAt: '2026-01-01T00:00:00.000Z', + userId: 'user-1', + cliType: 'codex', + agentType: 'codex', + project, + }, + { + id: childSessionId, + machineId: 'machine-1', + createdAt: '2026-01-01T00:00:00.000Z', + userId: 'user-1', + cliType: 'codex', + agentType: 'codex', + project, + parentSessionId: rootSessionId, + }, + ] as SessionMeta[], + includeLegacySessionDeleteRequest: false, + machineFlockRows: [ + { + key: localProjectKey, + value: { + id: localProjectId, + name: 'Project', + rootPath: '/repo', + createdAtMs: 1, + }, + }, + ], + }); + + await harness.handler.deleteLocalProjectResources(localProjectId, { + v: 1, + requestedAt: 7, + }); + + expect(commit).toHaveBeenCalledOnce(); + expect(commit).toHaveBeenCalledWith({ + operationId: `local-project-removal:${localProjectId}:7:${rootSessionId}`, + subjectId: rootSessionId, + targetIds: [rootSessionId, childSessionId], + state: 'archived', + }); + expect( + harness.repo.upsertDocMeta.mock.calls.some(([, patch]) => + Object.hasOwn(patch as object, 'isArchived') + ) + ).toBe(false); + }); + it('stops an archived active child session whose parent is already archived', async () => { const localProjectId = 'local-project-orphan-child' as LocalProjectId; const childSessionId = 'session-project-orphan-child' as SessionId; diff --git a/e2e/COVERAGE.md b/e2e/COVERAGE.md index 1c9f5f63b..37b3e2ba2 100644 --- a/e2e/COVERAGE.md +++ b/e2e/COVERAGE.md @@ -35,7 +35,7 @@ Backlog rows are evidence-backed gaps, not executable or promised scenarios. | `LODY-SEARCH-001` | Search, rename, revisit, archive, and delete across a three-Session index | Search palette queries, rename refresh, Session navigation, Archive, and UI revisit | Real window and Session RPC | Real owned runtime and three isolated Session histories | Three named Sessions across rename, UI revisit, Archive, and deletion | Scripted ACP | | `LODY-SESSION-002` | Rename, pin, archive, and restore a local Session | Session metadata and Archive UI | Real window and Session RPC | Real owned runtime | UI-created history, title, pin, archive, and restore | Scripted ACP | | `LODY-SESSION-003` | Mark a Session unread and clear the state by opening it | Session unread indicator, context menu, and sidebar navigation | Real window and Session RPC | Real owned runtime and Session histories | Two Sessions plus unread and read receipt transitions | Scripted ACP | -| `LODY-SESSION-004` | Preserve opened worktree Sessions through opener archive and delete | Session archive, Archive delete, child Tabs, and opened-by provenance | Real window, local repo, terminal, and Session RPC | Real archive and worktree cleanup command processing | Containment deletion, provenance retention, and worktree survival | Scripted fork-capable ACP | +| `LODY-SESSION-004` | Preserve opened worktree Sessions through opener archive and delete | Session archive, Archive delete, child Tabs, and opened-by provenance | Real window, local repo, terminal, and Session RPC | Real archive and worktree cleanup command processing | Atomic lifecycle replay, containment deletion, provenance retention, and worktree survival | Scripted fork-capable ACP | | `LODY-SETTINGS-001` | Cancel a theme preview without replacing the committed appearance | Appearance selector and live theme root | Real Electron window | Real owned runtime bootstrap | Committed theme in isolated local storage | None | ## Evidence-backed backlog diff --git a/e2e/journeys/registry.json b/e2e/journeys/registry.json index 6055c16da..522cbf11f 100644 --- a/e2e/journeys/registry.json +++ b/e2e/journeys/registry.json @@ -494,7 +494,7 @@ "owner": "session-lifecycle", "feature": "src/features/session-management.feature", "fixture": "session-fork-and-seeded-relations", - "fingerprint": "bf5df94ba0e024a2ac8e9c34b0bd8879e821e67337be85bc01732979e8e05b87", + "fingerprint": "032c2d07f90233dd7a4c70f86a18d0094287750d30fac283c11337839639ed12", "ownerPaths": [ "packages/components/src/hooks/use-session-actions.ts", "packages/components/src/lib/session-navigation.ts", @@ -504,7 +504,9 @@ { "id": "session.createCompletedSource" }, { "id": "session.forkWorktree", "args": { "count": 2 } }, { "id": "session.seedRelations" }, + { "id": "session.injectLifecyclePublishFailure" }, { "id": "session.archiveOpener" }, + { "id": "session.reloadPendingLifecycle" }, { "id": "archive.deletePermanently" }, { "id": "session.openSurvivors" }, { "id": "session.reloadWithMetadataScanBlocked" }, @@ -512,6 +514,7 @@ ], "checkpoints": [ "archive affects only the root and direct child Tab", + "durable lifecycle admission replays the same operation after publication failure", "permanent delete removes only the root and direct child Tab", "opened Sessions retain documents, provenance, running ACP processes, and worktrees", "dangling opener provenance is visible and non-navigable", @@ -525,7 +528,7 @@ "renderer": "Session archive, Archive delete, child Tabs, and opened-by provenance", "electronIpc": "Real window, local repo, terminal, and Session RPC", "bundledCli": "Real archive and worktree cleanup command processing", - "durableState": "Containment deletion, provenance retention, and worktree survival", + "durableState": "Atomic lifecycle replay, containment deletion, provenance retention, and worktree survival", "externalWire": "Scripted fork-capable ACP" }, "signals": { diff --git a/e2e/src/features/session-management.feature b/e2e/src/features/session-management.feature index 6ca73d11f..999f29415 100644 --- a/e2e/src/features/session-management.feature +++ b/e2e/src/features/session-management.feature @@ -16,6 +16,8 @@ 场景: opener 删除不销毁独立 opened Session 假如 已配置支持分叉的确定性 Agent 桌面 并且 已建立含 child Tab 和两个独立 worktree 的 Session 关系 - 当 用户归档并永久删除 opener Session + 当 用户在一次 lifecycle 发布失败下归档 opener Session 并重载 + 那么 同一 durable lifecycle 操作完整覆盖 opener 与 child Tab + 当 用户永久删除 opener Session 那么 child Tab 被删除而 opened Sessions 和 worktree 保留 并且 metadata 未完成 hydration 时精确删除 empty child Tab 仍成功 diff --git a/e2e/src/steps/session-management.steps.ts b/e2e/src/steps/session-management.steps.ts index 2cff9fc88..1353e0395 100644 --- a/e2e/src/steps/session-management.steps.ts +++ b/e2e/src/steps/session-management.steps.ts @@ -42,10 +42,19 @@ Given('已建立含 child Tab 和两个独立 worktree 的 Session 关系', asyn await this.sessionRelationLifecyclePage!.seedRelationLifecycle(firstFork, secondFork); }); -When('用户归档并永久删除 opener Session', async function (this: LodyWorld) { +When('用户在一次 lifecycle 发布失败下归档 opener Session 并重载', async function (this: LodyWorld) { await this.sessionRelationLifecyclePage!.archiveRelationRoot( this.sessionRelationLifecycleResources! ); +}); + +Then('同一 durable lifecycle 操作完整覆盖 opener 与 child Tab', async function (this: LodyWorld) { + await this.sessionRelationLifecyclePage!.expectRecoveredLifecycleArchive( + this.sessionRelationLifecycleResources! + ); +}); + +When('用户永久删除 opener Session', async function (this: LodyWorld) { await this.sessionRelationLifecyclePage!.permanentlyDeleteRelationRoot( this.sessionRelationLifecycleResources! ); diff --git a/e2e/src/support/fixtures/session-relation-lifecycle-fixture.ts b/e2e/src/support/fixtures/session-relation-lifecycle-fixture.ts index 8d9f769a4..a9e7120f0 100644 --- a/e2e/src/support/fixtures/session-relation-lifecycle-fixture.ts +++ b/e2e/src/support/fixtures/session-relation-lifecycle-fixture.ts @@ -30,9 +30,13 @@ type SeedRepo = { declare global { interface Window { repo?: SeedRepo; + __LODY_E2E_LIFECYCLE_PUBLISH_FAILURE__?: { attempts: number }; } } +const LIFECYCLE_DOC_ID = '_lody/session-lifecycle-operations/v1'; +const LIFECYCLE_FIELD_PREFIX = 'operation:'; + export const RELATION_TAB_ID = '20000000-0000-4000-8000-000000000002'; export const RELATION_TAB_TITLE = 'Contained tab T'; export const OPENED_SESSION_TITLE = 'Independent opened Session B'; @@ -49,6 +53,89 @@ export type RelationGraphSeed = { }; export class SessionRelationLifecycleFixture { + async failNextLifecyclePublication(page: Page): Promise { + await page.evaluate( + ({ docId }) => { + const repo = window.repo; + if (!repo) throw new Error('Renderer workspace repo is unavailable'); + const original = repo.upsertDocMeta.bind(repo); + const control = { attempts: 0 }; + repo.upsertDocMeta = async (targetDocId, patch) => { + if (targetDocId === docId && control.attempts === 0) { + control.attempts += 1; + throw new Error('injected lifecycle publication failure'); + } + await original(targetDocId, patch); + }; + window.__LODY_E2E_LIFECYCLE_PUBLISH_FAILURE__ = control; + }, + { docId: LIFECYCLE_DOC_ID } + ); + } + + async readLifecycleEvidence( + page: Page, + subjectId: string + ): Promise<{ + operationId: string; + targetIds: string[]; + state: string; + journalPublished: boolean; + replicated: boolean; + injectedFailures: number; + } | null> { + return await page.evaluate( + async ({ docId, fieldPrefix, subjectId: evaluatedSubjectId }) => { + const repo = window.repo; + if (!repo || !window.ipc) throw new Error('Local workspace repo is unavailable'); + const snapshot = (await window.ipc.invoke('localPlatform.getSnapshot')) as { + workspace: { workspaceId: string }; + }; + const openRequest = indexedDB.open( + `lody-session-lifecycle-v1:${snapshot.workspace.workspaceId}` + ); + const database = await new Promise((resolve, reject) => { + openRequest.onsuccess = () => resolve(openRequest.result); + openRequest.onerror = () => reject(openRequest.error); + }); + const transaction = database.transaction('admissions', 'readonly'); + const getAllRequest = transaction.objectStore('admissions').getAll(); + const rows = await new Promise< + Array<{ operationId: string; operationJson: string; published: boolean }> + >((resolve, reject) => { + getAllRequest.onsuccess = () => resolve(getAllRequest.result); + getAllRequest.onerror = () => reject(getAllRequest.error); + }); + await new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onabort = () => reject(transaction.error); + transaction.onerror = () => reject(transaction.error); + }); + database.close(); + const candidates = rows + .map((row) => ({ + row, + operation: JSON.parse(row.operationJson) as Record, + })) + .filter(({ operation }) => operation.subjectId === evaluatedSubjectId); + const admission = candidates.at(-1); + if (!admission) return null; + const replicatedRecord = await repo.getDocMeta(docId); + const replicatedValue = + replicatedRecord?.meta?.[`${fieldPrefix}${admission.row.operationId}`]; + return { + operationId: admission.row.operationId, + targetIds: admission.operation.targetIds as string[], + state: String(admission.operation.state), + journalPublished: admission.row.published, + replicated: typeof replicatedValue === 'string', + injectedFailures: window.__LODY_E2E_LIFECYCLE_PUBLISH_FAILURE__?.attempts ?? 0, + }; + }, + { docId: LIFECYCLE_DOC_ID, fieldPrefix: LIFECYCLE_FIELD_PREFIX, subjectId } + ); + } + async seedRelationGraph(page: Page, relationGraph: RelationGraphSeed): Promise { await page.evaluate( async ({ graph, openedFromTabTitle, openedTitle, tabId, tabTitle }) => { diff --git a/e2e/src/support/pages/session-relation-lifecycle-page.ts b/e2e/src/support/pages/session-relation-lifecycle-page.ts index 5ce0e8a85..c2edb2e28 100644 --- a/e2e/src/support/pages/session-relation-lifecycle-page.ts +++ b/e2e/src/support/pages/session-relation-lifecycle-page.ts @@ -24,6 +24,7 @@ export type SessionRelationLifecycleResources = { export class SessionRelationLifecyclePage { private readonly fixture = new SessionRelationLifecycleFixture(); + private pendingArchiveOperationId: string | null = null; constructor(private readonly page: Page) {} @@ -54,6 +55,7 @@ export class SessionRelationLifecyclePage { } async archiveRelationRoot(resources: SessionRelationLifecycleResources): Promise { + await this.fixture.failNextLifecyclePublication(this.page); await this.openRowMenu(this.activeRowById(resources.rootSessionId)); await this.page.getByRole('menuitem', { name: /^(Archive Session|归档会话)$/u }).click(); await expect(this.activeRowById(resources.rootSessionId)).toBeHidden({ timeout: 30_000 }); @@ -65,6 +67,53 @@ export class SessionRelationLifecyclePage { intervals: [50, 100, 250, 500], }) .toEqual({ root: true, tab: true, opened: false, openedFromTab: false }); + + await expect + .poll(() => this.fixture.readLifecycleEvidence(this.page, resources.rootSessionId), { + timeout: 30_000, + intervals: [50, 100, 250, 500], + }) + .toMatchObject({ + targetIds: [resources.rootSessionId, resources.tabSessionId].sort(), + state: 'archived', + journalPublished: false, + replicated: false, + injectedFailures: 1, + }); + const pendingEvidence = await this.fixture.readLifecycleEvidence( + this.page, + resources.rootSessionId + ); + if (!pendingEvidence) throw new Error('Lifecycle admission was not persisted'); + this.pendingArchiveOperationId = pendingEvidence.operationId; + + await this.page.reload({ waitUntil: 'domcontentloaded' }); + } + + async expectRecoveredLifecycleArchive( + resources: SessionRelationLifecycleResources + ): Promise { + if (!this.pendingArchiveOperationId) { + throw new Error('Pending lifecycle operation identity was not captured'); + } + await expect + .poll(() => this.readArchiveStates(resources), { + timeout: 30_000, + intervals: [50, 100, 250, 500], + }) + .toEqual({ root: true, tab: true, opened: false, openedFromTab: false }); + await expect + .poll(() => this.fixture.readLifecycleEvidence(this.page, resources.rootSessionId), { + timeout: 30_000, + intervals: [50, 100, 250, 500], + }) + .toMatchObject({ + operationId: this.pendingArchiveOperationId, + targetIds: [resources.rootSessionId, resources.tabSessionId].sort(), + state: 'archived', + journalPublished: true, + replicated: true, + }); } async permanentlyDeleteRelationRoot(resources: SessionRelationLifecycleResources): Promise { diff --git a/packages/components/src/atoms/doc-meta.ts b/packages/components/src/atoms/doc-meta.ts index db75ce172..0105fa185 100644 --- a/packages/components/src/atoms/doc-meta.ts +++ b/packages/components/src/atoms/doc-meta.ts @@ -766,6 +766,26 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => { } } + // Register before the repo facade's synthesized per-session events. The + // lifecycle owner has already installed the complete revision, and this one + // cache write makes every target visible together to React consumers. + const unsubscribeLifecycle = runtime.sessionLifecycle?.subscribe(({ revision, previous }) => { + const targetIds = new Set([...previous.bySessionId.keys(), ...revision.bySessionId.keys()]); + set(sessionMetaCacheAtom, (prev) => { + let next = prev; + for (const sessionId of targetIds) { + const roomId = `${SESSION_DOC_PREFIX}${sessionId}`; + const current = next[roomId]; + if (!current) continue; + const isArchived = revision.bySessionId.get(sessionId)?.state === 'archived'; + if (current.isArchived === isArchived) continue; + if (next === prev) next = { ...prev }; + next[roomId] = { ...current, isArchived }; + } + return next; + }); + }); + const handle = runtime.repo.watch( (event) => { if (event.kind === 'doc-metadata') { @@ -836,6 +856,7 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => { clearTimeout(flushTimer); flushTimer = null; } + unsubscribeLifecycle?.(); handle.unsubscribe(); }; }); diff --git a/packages/components/src/atoms/runtime.ts b/packages/components/src/atoms/runtime.ts index b292518d1..e090a10d2 100644 --- a/packages/components/src/atoms/runtime.ts +++ b/packages/components/src/atoms/runtime.ts @@ -67,6 +67,7 @@ import type { CodeCollabV2SaveTextResponse, FilePreviewV3Request, FilePreviewV3Response, + SessionLifecycleRepository, } from '@lody/shared'; import type { LocalProjectGitStateRpcResponse } from '@lody/loro-streams-rpc'; import type { WorkspaceWriter } from '../providers/workspace-writer'; @@ -159,6 +160,8 @@ export type WorkspaceRuntime = { */ readonly workspaceId: WorkspaceId; readonly repo: LoroRepo; + /** Null for product topologies whose independently deployed writers cannot yet be fenced. */ + readonly sessionLifecycle: SessionLifecycleRepository | null; /** Workspace-owned, scoped LRU for owner-session file-index Flock resources. */ readonly codeCollabFileIndexCache: CodeCollabFileIndexCache; /** diff --git a/packages/components/src/hooks/README.md b/packages/components/src/hooks/README.md index 811b3aec5..fb28b18ff 100644 --- a/packages/components/src/hooks/README.md +++ b/packages/components/src/hooks/README.md @@ -85,6 +85,17 @@ ACP/CRDT snapshot can queue minutes of React work behind a long active turn and retain every obsolete history tree, so history-only bursts coalesce to the latest snapshot once per animation frame while control state stays synchronous. +## `use-session-actions.ts` + +Archive and restore discover the selected Session and direct child Tabs from one +repository snapshot. In a local-only runtime they submit one immutable lifecycle +operation through the workspace writer; terminal cleanup starts only after durable +admission. The repository projection, not the rendered cache or raw `isArchived`, is +the authority. Cloud and dual runtimes retain the legacy compatibility path until the +product writer-admission gate in the +[Session lifecycle decision](../../../../.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md) +is available. + ## `use-app-store-review-prompt.ts` The stored list of the newest 50 completed-turn timestamps answers the whole diff --git a/packages/components/src/hooks/use-session-actions.ts b/packages/components/src/hooks/use-session-actions.ts index fd05807db..2fd89c47e 100644 --- a/packages/components/src/hooks/use-session-actions.ts +++ b/packages/components/src/hooks/use-session-actions.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import { useCloudMutation } from '@lody/platform/react'; import { cloudOperations } from '@/lib/cloud-api-operations'; import { useCloudQuery } from '@lody/platform/react'; @@ -32,9 +32,12 @@ import { formatSessionQuotaRejection, isConvexUnauthenticatedError, isLoroRepoDocDeleted, + isSessionDocRoomId, normalizeSessionTurnInputConfig, readMachineFlockRowsFromFlock, sanitizeMessageTextSpans, + SessionLifecycleAdmissionUncertainError, + type SessionLifecycleOperationDraft, } from '@lody/shared'; import { useAtomValue, useSetAtom, useStore } from 'jotai'; import { usePostHog } from '@posthog/react'; @@ -57,6 +60,8 @@ import { import { resolveSessionCreateRepoFullName } from '@/lib/session-repo'; import { capturePostHogEvent } from '@/lib/posthog-analytics'; import { sendIpc } from '@/lib/electron-ipc-client'; +import { listDocMetaEntries } from '@/lib/doc-meta-batch'; +import { withDerivedDocMetaId } from '@/lib/doc-meta-room'; import { useAuthenticatedConvex } from './use-authenticated-convex'; const log = debug('lody:session-actions'); @@ -243,6 +248,90 @@ function getArchiveStateTargets( ]; } +async function listSessionMetadataSnapshot(runtime: WorkspaceRuntime): Promise { + const entries = await listDocMetaEntries(runtime.repo); + return entries.flatMap((entry) => { + if ( + !isSessionDocRoomId(entry.docId) || + isLoroRepoDocDeleted(entry) || + Object.keys(entry.meta).length === 0 + ) { + return []; + } + return [withDerivedDocMetaId(entry.docId, entry.meta) as SessionMeta]; + }); +} + +async function writeLegacyArchiveState( + runtime: WorkspaceRuntime, + rootSessionId: SessionId, + archiveTargets: readonly SessionMeta[] +): Promise { + const root = archiveTargets.find((session) => session.id === rootSessionId); + if (!root) { + throw new Error(`Archive root metadata missing for ${rootSessionId}`); + } + + // There is no cross-document transaction in LoroRepo. Commit children before + // the root so a later child failure can never leave the root archived while + // that child remains active. The root is the final commit point. + const writeTargets = [...archiveTargets.filter((session) => session.id !== rootSessionId), root]; + const touchedSessions: SessionMeta[] = []; + + try { + for (const session of writeTargets) { + // Include the current target before awaiting: a rejected writer call may + // have accepted a local mutation before surfacing a later failure. + touchedSessions.push(session); + await runtime.writer.upsertDocMeta(getSessionRoomId(session.id), { + isArchived: true, + status: SessionStatusFactory.idle(), + } as Partial); + } + } catch (archiveError) { + const compensationFailures: unknown[] = []; + const attemptedRoot = touchedSessions.find((session) => session.id === rootSessionId); + + // Restore the root first when its final write was attempted. Only then may + // children be restored, preserving root-archived => children-archived even + // if a compensating child write also fails. + if (attemptedRoot) { + try { + await runtime.writer.upsertDocMeta(getSessionRoomId(attemptedRoot.id), { + isArchived: attemptedRoot.isArchived, + status: attemptedRoot.status, + } as Partial); + } catch (rollbackError) { + compensationFailures.push(rollbackError); + } + } + + if (!attemptedRoot || compensationFailures.length === 0) { + for (const session of [...touchedSessions].reverse()) { + if (session.id === rootSessionId) continue; + try { + await runtime.writer.upsertDocMeta(getSessionRoomId(session.id), { + isArchived: session.isArchived, + status: session.status, + } as Partial); + } catch (rollbackError) { + compensationFailures.push(rollbackError); + } + } + } + + if (compensationFailures.length > 0) { + const failure = new Error( + `Archive failed and ${compensationFailures.length} lifecycle rollback(s) also failed`, + { cause: archiveError } + ); + Object.assign(failure, { compensationFailures }); + throw failure; + } + throw archiveError; + } +} + async function assertArchivedLocalProjectCanRestore( runtime: WorkspaceRuntime, sessionMeta: SessionMeta @@ -490,6 +579,7 @@ export function useSessionActions(): SessionActions { const runtime = useAtomValue(activeWorkspaceRuntimeAtom); const setDocMetaByRoomId = useSetAtom(setDocMetaByRoomIdAtom); const store = useStore(); + const uncertainLifecycleOperationIds = useRef(new Map()); // Convex dedupes identical subscriptions client-side, so this shares the // entitlement subscription already held by the chat surfaces. const billingEntitlement = useCloudQuery( @@ -517,6 +607,30 @@ export function useSessionActions(): SessionActions { [isConvexAuthenticated, recordMyWorkspaceDailyActiveUser, requestAuthRecovery] ); + const commitSessionLifecycle = useCallback( + async (draft: Omit): Promise => { + if (!runtime?.sessionLifecycle) throw new Error('Session lifecycle owner is unavailable'); + const retryKey = JSON.stringify([ + runtime.workspaceId, + draft.subjectId, + draft.state, + draft.targetIds, + ]); + const operationId = uncertainLifecycleOperationIds.current.get(retryKey) ?? uuidv4(); + uncertainLifecycleOperationIds.current.set(retryKey, operationId); + try { + await runtime.writer.commitSessionLifecycle({ ...draft, operationId }); + uncertainLifecycleOperationIds.current.delete(retryKey); + } catch (error) { + if (!(error instanceof SessionLifecycleAdmissionUncertainError)) { + uncertainLifecycleOperationIds.current.delete(retryKey); + } + throw error; + } + }, + [runtime] + ); + const assertSessionCreateAllowed = useCallback( (sessionId: SessionId) => { if (!runtime?.workspaceId) return; @@ -1165,46 +1279,53 @@ export function useSessionActions(): SessionActions { throw new Error('Runtime not ready'); } - const sessionRoomId = getSessionRoomId(sessionId); - const repoMeta = (await runtime.repo.getDocMeta(sessionRoomId))?.meta as - | SessionMeta - | undefined; - // The repo read is preferred (freshest lifecycle fields), but it can lag - // a session the UI already renders. The archive write below is an - // idempotent patch, so the rendered meta cache is enough to proceed — a - // session the UI can show must also be closable. - const sessionMeta = - repoMeta ?? (store.get(sessionMetaCacheAtom)[sessionRoomId] as SessionMeta | undefined); - if (!sessionMeta) { + const sessionMetadata = await listSessionMetadataSnapshot(runtime); + if (store.get(activeWorkspaceRuntimeAtom) !== runtime) { + throw new Error('Workspace changed while loading session metadata'); + } + const repoMeta = sessionMetadata.find((session) => session.id === sessionId); + if (!repoMeta) { throw new Error(`Session metadata missing for ${sessionId}`); } log('[session-archive] session meta loaded', { sessionId, - machineId: sessionMeta.machineId, + machineId: repoMeta.machineId, }); - const archiveTargets = getArchiveStateTargets( - sessionId, - sessionMeta, - Object.values(store.get(sessionMetaCacheAtom)) - ); - for (const session of archiveTargets) { - if (typeof window !== 'undefined') { - sendIpc('terminal.closeSession', { sessionId: session.id }); + const archiveTargets = getArchiveStateTargets(sessionId, repoMeta, sessionMetadata); + // Durable admission is the commit boundary. The captured runtime owns + // publication/replay even if the active workspace changes afterwards. + if (runtime.sessionLifecycle) { + await commitSessionLifecycle({ + subjectId: sessionId, + targetIds: archiveTargets.map((session) => session.id), + state: 'archived', + }); + } else { + await writeLegacyArchiveState(runtime, sessionId, archiveTargets); + } + + // Metadata is authoritative. Close terminals only after every lifecycle + // target has committed so a failed archive has no partial terminal side + // effects. IPC cleanup is best effort and must not hide a durable commit. + if (typeof window !== 'undefined') { + for (const session of archiveTargets) { + try { + sendIpc('terminal.closeSession', { sessionId: session.id }); + } catch (error) { + log('[session-archive] terminal close failed after metadata commit', { + sessionId: session.id, + error, + }); + } } - // The archived state is the whole request: the owning machine observes - // it, releases the runtime, and reconciles the worktree directory. - await runtime.writer.upsertDocMeta(getSessionRoomId(session.id), { - isArchived: true, - status: SessionStatusFactory.idle(), - } as Partial); } log('[session-archive] archived', { sessionId, targetSessionIds: archiveTargets.map((session) => session.id), }); }, - [runtime, store] + [commitSessionLifecycle, runtime, store] ); const restoreSession = useCallback( @@ -1222,23 +1343,31 @@ export function useSessionActions(): SessionActions { throw new Error(`Session metadata missing for ${sessionId}`); } await assertArchivedLocalProjectCanRestore(runtime, sessionMeta); - const archiveTargets = getArchiveStateTargets( - sessionId, - sessionMeta, - Object.values(store.get(sessionMetaCacheAtom)) - ); + const sessionMetadata = await listSessionMetadataSnapshot(runtime); + if (store.get(activeWorkspaceRuntimeAtom) !== runtime) { + throw new Error('Workspace changed while loading session metadata'); + } + const archiveTargets = getArchiveStateTargets(sessionId, sessionMeta, sessionMetadata); - for (const session of archiveTargets) { - await runtime.writer.upsertDocMeta(getSessionRoomId(session.id), { - isArchived: false, - } as Partial); + if (runtime.sessionLifecycle) { + await commitSessionLifecycle({ + subjectId: sessionId, + targetIds: archiveTargets.map((session) => session.id), + state: 'active', + }); + } else { + for (const session of archiveTargets) { + await runtime.writer.upsertDocMeta(getSessionRoomId(session.id), { + isArchived: false, + } as Partial); + } } log('[session-restore] restored', { sessionId, targetSessionIds: archiveTargets.map((session) => session.id), }); }, - [runtime, store] + [commitSessionLifecycle, runtime, store] ); const deleteArchivedSessionMeta = useCallback( diff --git a/packages/components/src/lib/doc-meta-batch.ts b/packages/components/src/lib/doc-meta-batch.ts index 4b4ba57de..55e52b52f 100644 --- a/packages/components/src/lib/doc-meta-batch.ts +++ b/packages/components/src/lib/doc-meta-batch.ts @@ -1,4 +1,11 @@ import type { LoroRepo } from 'loro-repo'; +import { + getAttachedSessionLifecycleRepository, + isSessionDocRoomId, + projectSessionLifecycleMetadata, + SESSION_DOC_PREFIX, + type SessionId, +} from '@lody/shared'; import { collectDocExistenceValues, collectDocMetadataPatchesFromEntries } from './flock-existence'; type FlockScanRow = { @@ -58,10 +65,22 @@ async function tryListDocMetaEntriesFromFlock(repo: LoroRepo): Promise { const batchedEntries = await tryListDocMetaEntriesFromFlock(repo); - if (batchedEntries) return batchedEntries; - const entries = await repo.listDoc(); + const entries = + batchedEntries ?? + (await repo.listDoc()).map((entry) => ({ + ...entry, + meta: entry.meta as Record, + })); + const lifecycle = getAttachedSessionLifecycleRepository(repo); return entries.map((entry) => ({ ...entry, - meta: entry.meta as Record, + meta: + lifecycle && isSessionDocRoomId(entry.docId) + ? projectSessionLifecycleMetadata( + lifecycle, + entry.docId.slice(SESSION_DOC_PREFIX.length) as SessionId, + entry.meta + ) + : entry.meta, })); } diff --git a/packages/components/src/lib/session-lifecycle-persistence.ts b/packages/components/src/lib/session-lifecycle-persistence.ts new file mode 100644 index 000000000..f8fad36e0 --- /dev/null +++ b/packages/components/src/lib/session-lifecycle-persistence.ts @@ -0,0 +1,197 @@ +import { + canonicalizeSessionLifecycleOperation, + encodeSessionLifecycleOperation, + parseSessionLifecycleOperation, + SessionLifecycleAdmissionRejectedError, + SessionLifecycleOperationConflictError, + type SessionLifecycleAdmission, + type SessionLifecycleAdmissionStore, + type SessionLifecycleOperationDraft, +} from '@lody/shared'; + +const DB_VERSION = 1; +const ADMISSIONS_STORE = 'admissions'; +const STATE_STORE = 'state'; +const HIGH_WATER_KEY = 'highWater'; + +type PersistedAdmission = { + operationId: string; + operationJson: string; + published: boolean; +}; + +type PersistedState = { key: string; value: string }; + +type AdmissionFaults = { + beforeWrite?: () => void; + afterCommit?: () => void; +}; + +const requestResult = (request: IDBRequest): Promise => + new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); + +const transactionComplete = (transaction: IDBTransaction): Promise => + new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted')); + transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed')); + }); + +function decodeAdmission(value: PersistedAdmission): SessionLifecycleAdmission { + return { + operation: parseSessionLifecycleOperation(JSON.parse(value.operationJson)), + published: value.published, + }; +} + +function draftMatchesAdmission( + draft: SessionLifecycleOperationDraft, + admission: SessionLifecycleAdmission +): boolean { + return ( + encodeSessionLifecycleOperation(admission.operation) === + encodeSessionLifecycleOperation( + canonicalizeSessionLifecycleOperation({ + version: 1, + ...draft, + order: admission.operation.order, + }) + ) + ); +} + +export function getSessionLifecycleIndexedDbName(workspaceId: string): string { + return `lody-session-lifecycle-v1:${workspaceId}`; +} + +export async function createIndexedDbSessionLifecycleAdmissionStore(options: { + workspaceId: string; + indexedDB?: IDBFactory; + faults?: AdmissionFaults; +}): Promise { + const factory = options.indexedDB ?? globalThis.indexedDB; + if (!factory) throw new Error('IndexedDB is unavailable for Session lifecycle persistence'); + const openRequest = factory.open(getSessionLifecycleIndexedDbName(options.workspaceId), DB_VERSION); + openRequest.onupgradeneeded = () => { + const database = openRequest.result; + if (!database.objectStoreNames.contains(ADMISSIONS_STORE)) { + database.createObjectStore(ADMISSIONS_STORE, { keyPath: 'operationId' }); + } + if (!database.objectStoreNames.contains(STATE_STORE)) { + database.createObjectStore(STATE_STORE, { keyPath: 'key' }); + } + }; + const database = await requestResult(openRequest); + + const readAdmission = async (operationId: string): Promise => { + const transaction = database.transaction(ADMISSIONS_STORE, 'readonly'); + const value = (await requestResult( + transaction.objectStore(ADMISSIONS_STORE).get(operationId) + )) as PersistedAdmission | undefined; + await transactionComplete(transaction); + return value ? decodeAdmission(value) : undefined; + }; + + return { + async list() { + const transaction = database.transaction(ADMISSIONS_STORE, 'readonly'); + const values = (await requestResult( + transaction.objectStore(ADMISSIONS_STORE).getAll() + )) as PersistedAdmission[]; + await transactionComplete(transaction); + return values.map(decodeAdmission); + }, + get: readAdmission, + async admit(draft, actorId, observedCounter) { + try { + options.faults?.beforeWrite?.(); + } catch (cause) { + throw new SessionLifecycleAdmissionRejectedError( + cause instanceof Error ? cause.message : 'IndexedDB lifecycle admission rejected', + { cause } + ); + } + const transaction = database.transaction([ADMISSIONS_STORE, STATE_STORE], 'readwrite'); + const admissions = transaction.objectStore(ADMISSIONS_STORE); + const state = transaction.objectStore(STATE_STORE); + const existingValue = (await requestResult(admissions.get(draft.operationId))) as + | PersistedAdmission + | undefined; + if (existingValue) { + const existing = decodeAdmission(existingValue); + if (!draftMatchesAdmission(draft, existing)) { + transaction.abort(); + throw new SessionLifecycleOperationConflictError(draft.operationId); + } + await transactionComplete(transaction); + return existing; + } + const highWaterValue = (await requestResult(state.get(HIGH_WATER_KEY))) as + | PersistedState + | undefined; + const counter = + (BigInt(highWaterValue?.value ?? '0') > BigInt(observedCounter) + ? BigInt(highWaterValue?.value ?? '0') + : BigInt(observedCounter)) + 1n; + const operation = canonicalizeSessionLifecycleOperation({ + version: 1, + ...draft, + order: { counter: counter.toString(10), actorId }, + }); + admissions.put({ + operationId: operation.operationId, + operationJson: encodeSessionLifecycleOperation(operation), + published: false, + } satisfies PersistedAdmission); + state.put({ key: HIGH_WATER_KEY, value: counter.toString(10) } satisfies PersistedState); + await transactionComplete(transaction); + options.faults?.afterCommit?.(); + return { operation, published: false }; + }, + async observeCounter(counter) { + const transaction = database.transaction(STATE_STORE, 'readwrite'); + const state = transaction.objectStore(STATE_STORE); + const current = (await requestResult(state.get(HIGH_WATER_KEY))) as PersistedState | undefined; + if (BigInt(counter) > BigInt(current?.value ?? '0')) { + state.put({ key: HIGH_WATER_KEY, value: counter } satisfies PersistedState); + } + await transactionComplete(transaction); + }, + async seed(operation) { + const transaction = database.transaction(ADMISSIONS_STORE, 'readwrite'); + const store = transaction.objectStore(ADMISSIONS_STORE); + const existingValue = (await requestResult(store.get(operation.operationId))) as + | PersistedAdmission + | undefined; + if (existingValue) { + const existing = decodeAdmission(existingValue); + if (encodeSessionLifecycleOperation(existing.operation) !== encodeSessionLifecycleOperation(operation)) { + transaction.abort(); + throw new SessionLifecycleOperationConflictError(operation.operationId); + } + await transactionComplete(transaction); + return existing; + } + store.put({ + operationId: operation.operationId, + operationJson: encodeSessionLifecycleOperation(operation), + published: false, + } satisfies PersistedAdmission); + await transactionComplete(transaction); + return { operation, published: false }; + }, + async markPublished(operationId) { + const transaction = database.transaction(ADMISSIONS_STORE, 'readwrite'); + const store = transaction.objectStore(ADMISSIONS_STORE); + const value = (await requestResult(store.get(operationId))) as PersistedAdmission | undefined; + if (value && !value.published) store.put({ ...value, published: true }); + await transactionComplete(transaction); + }, + async close() { + database.close(); + }, + }; +} diff --git a/packages/components/src/providers/create-workspace-runtime.ts b/packages/components/src/providers/create-workspace-runtime.ts index 9a9d448de..4f2ae23ed 100644 --- a/packages/components/src/providers/create-workspace-runtime.ts +++ b/packages/components/src/providers/create-workspace-runtime.ts @@ -69,10 +69,16 @@ import { type LoroStreamsTokenProviderEvent, type SyncReason, ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS, + createLoroMetaSessionLifecyclePublisher, + createSessionLifecycleBaselineOperation, + getSessionIdFromRoomId, + installSessionLifecycleRepoProjection, + SessionLifecycleRepository, } from '@lody/shared'; import { LocalLoroTransportAdapter } from '@lody/shared/local-loro-transport'; import type { TaskId, WorkspaceId } from '@lody/shared'; import { createDirectWorkspaceWriter } from './workspace-writer-impl'; +import { createIndexedDbSessionLifecycleAdmissionStore } from '@/lib/session-lifecycle-persistence'; import { WorkspaceTargetRouter, type WorkspaceTransportRoom, @@ -462,6 +468,49 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise {}); const syncMode: PlatformSyncMode = deps.syncMode ?? (isElectronLocalDataPlaneEnabled() ? 'dual' : 'cloud'); + // Plan 001 activation is deliberately limited to the coordinated OSS local + // topology. Cloud/dual workspaces can contain independently deployed writers, + // and the public repository has no admission fence that can prove they all + // understand lifecycle operations. + let sessionLifecycle: SessionLifecycleRepository | null = null; + if (syncMode === 'local') { + let admissionStore: + | Awaited> + | null = null; + try { + const rawSessionEntries = (await repo.listDoc()).filter( + (entry) => isSessionDocRoomId(entry.docId) && !isLoroRepoDocDeleted(entry) + ); + admissionStore = await createIndexedDbSessionLifecycleAdmissionStore({ + workspaceId: deps.workspaceId, + }); + sessionLifecycle = new SessionLifecycleRepository({ + actorId: `renderer:${desktopWindowId() || 'primary'}`, + store: admissionStore, + publisher: createLoroMetaSessionLifecyclePublisher(repo), + }); + await sessionLifecycle.initialize({ + baselines: rawSessionEntries.flatMap((entry) => { + const sessionId = getSessionIdFromRoomId(entry.docId); + return sessionId && entry.meta.isArchived === true + ? [createSessionLifecycleBaselineOperation(sessionId)] + : []; + }), + }); + installSessionLifecycleRepoProjection({ + repo, + repository: sessionLifecycle, + getSessionId: getSessionIdFromRoomId, + getSessionDocId: getSessionRoomId, + rejectLegacyWrites: true, + }); + } catch (error) { + if (sessionLifecycle) await sessionLifecycle.dispose().catch(() => undefined); + else await admissionStore?.close?.().catch(() => undefined); + await repo.destroy().catch(() => undefined); + throw error; + } + } // Historical name: true whenever a local plane exists (dual OR local). const electronLocalDataPlane = syncMode !== 'cloud'; // False only on the local-only platform: no Streams member, no token @@ -4134,6 +4183,7 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise undefined); + await sessionLifecycle.dispose(); + } try { await repo.destroy(); } catch (error) { @@ -4598,6 +4652,7 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise diff --git a/packages/components/src/providers/workspace-writer-impl.ts b/packages/components/src/providers/workspace-writer-impl.ts index 1e6e5f804..facdaa4a7 100644 --- a/packages/components/src/providers/workspace-writer-impl.ts +++ b/packages/components/src/providers/workspace-writer-impl.ts @@ -5,6 +5,7 @@ import { type MessageQueueItem, type PreviewVisualCommentDocInput, type SessionDocMeta, + type SessionLifecycleRepository, } from '@lody/shared'; import type { SessionId } from '@lody/shared/ids'; import type { LoroRepo } from 'loro-repo'; @@ -20,6 +21,7 @@ import type { WorkspaceWriter } from './workspace-writer'; /** Deps the writer needs from the runtime (repo + session stores). */ export type DirectWorkspaceWriterDeps = { repo: LoroRepo; + sessionLifecycle?: SessionLifecycleRepository | null; acquireSessionStore: (sessionId: SessionId) => Promise; releaseSessionStoreRef: (sessionId: SessionId) => void; acquirePreviewVisualCommentStore: (sessionId: SessionId) => Promise; @@ -70,6 +72,13 @@ export function createDirectWorkspaceWriter(deps: DirectWorkspaceWriterDeps): Wo await deps.repo.upsertDocMeta(roomId, patch as Parameters[1]); }, + async commitSessionLifecycle(draft) { + if (!deps.sessionLifecycle) { + throw new Error('Session lifecycle operations are not enabled for this workspace topology'); + } + return await deps.sessionLifecycle.commit(draft); + }, + async startSession(sessionId, meta, entry, dispatch) { await Promise.all([ deps.repo.upsertDocMeta( diff --git a/packages/components/src/providers/workspace-writer.ts b/packages/components/src/providers/workspace-writer.ts index 303ad177d..f9f8e8886 100644 --- a/packages/components/src/providers/workspace-writer.ts +++ b/packages/components/src/providers/workspace-writer.ts @@ -4,6 +4,8 @@ import type { SessionHistory, PermissionOutcome, TaskProposalMeta, + SessionLifecycleCommitReceipt, + SessionLifecycleOperationDraft, } from '@lody/shared'; // # WorkspaceWriter — the renderer's authored-write seam @@ -19,6 +21,11 @@ export interface WorkspaceWriter { /** `repo.upsertDocMeta(roomId, patch)` — session/machine doc-meta write. */ upsertDocMeta(roomId: string, patch: Record): Promise; + /** Atomically admit one archive/restore operation in enabled local topology. */ + commitSessionLifecycle( + draft: SessionLifecycleOperationDraft + ): Promise; + /** * Author a new session's meta and first user turn as one accept unit. The * durable dispatch pointer stays the caller's sibling side effect diff --git a/packages/components/src/stories/SessionLifecyclePersistence.stories.tsx b/packages/components/src/stories/SessionLifecyclePersistence.stories.tsx new file mode 100644 index 000000000..38334b723 --- /dev/null +++ b/packages/components/src/stories/SessionLifecyclePersistence.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { + createIndexedDbSessionLifecycleAdmissionStore, + getSessionLifecycleIndexedDbName, +} from '@/lib/session-lifecycle-persistence'; + +const SessionLifecyclePersistenceHarness = () => { + Object.assign(window, { + __lodySessionLifecyclePersistence: { + createStore: createIndexedDbSessionLifecycleAdmissionStore, + getDatabaseName: getSessionLifecycleIndexedDbName, + }, + }); + return
ready
; +}; + +const meta = { + title: 'Infrastructure/SessionLifecyclePersistence', + component: SessionLifecyclePersistenceHarness, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const BrowserHarness: Story = {}; diff --git a/packages/components/tests/doc-meta-subscription.test.ts b/packages/components/tests/doc-meta-subscription.test.ts index b58cbc2b1..32f971b4a 100644 --- a/packages/components/tests/doc-meta-subscription.test.ts +++ b/packages/components/tests/doc-meta-subscription.test.ts @@ -237,6 +237,92 @@ describe('docMetaSubscriptionAtom', () => { } }); + it('publishes all lifecycle targets to the UI cache in one revision', async () => { + const rootId = 'atomic-root' as SessionId; + const childId = 'atomic-child' as SessionId; + const rootRoomId = getSessionRoomId(rootId); + const childRoomId = getSessionRoomId(childId); + const repo = new CompatRepoDouble([ + { + docId: rootRoomId, + exists: true, + meta: { + id: rootId, + title: 'Root', + createdAt: '2026-09-14T00:00:00.000Z', + isArchived: false, + }, + }, + { + docId: childRoomId, + exists: true, + meta: { + id: childId, + parentSessionId: rootId, + title: 'Child', + createdAt: '2026-09-14T00:01:00.000Z', + isArchived: false, + }, + }, + ]); + let lifecycleListener: + | ((event: { + previous: { bySessionId: Map }; + revision: { + bySessionId: Map< + SessionId, + { operationId: string; state: 'archived'; order: { counter: string; actorId: string } } + >; + }; + }) => void) + | undefined; + const runtime = { + ...createRuntime(repo as unknown as LoroRepo), + sessionLifecycle: { + subscribe: (listener: typeof lifecycleListener) => { + lifecycleListener = listener; + return () => { + lifecycleListener = undefined; + }; + }, + }, + } as unknown as WorkspaceRuntime; + const store = createStore(); + const unmountSubscription = store.sub(docMetaSubscriptionAtom, () => {}); + const observed: Array<[boolean | undefined, boolean | undefined]> = []; + const unmountCache = store.sub(sessionMetaCacheAtom, () => { + const cache = store.get(sessionMetaCacheAtom); + observed.push([cache[rootRoomId]?.isArchived, cache[childRoomId]?.isArchived]); + }); + + try { + store.set(runtimeAtom, runtime); + await flush(); + observed.length = 0; + + lifecycleListener?.({ + previous: { bySessionId: new Map() }, + revision: { + bySessionId: new Map( + [rootId, childId].map((sessionId) => [ + sessionId, + { + operationId: 'archive-both', + state: 'archived' as const, + order: { counter: '1', actorId: 'test' }, + }, + ]) + ), + }, + }); + + expect(observed).toEqual([[true, true]]); + } finally { + unmountCache(); + unmountSubscription(); + } + }); + it('bootstraps and updates machine metadata from real loro-repo watch events', async () => { const repo = (await LoroRepo.create({})) as RepoWithSyncRunner; const remote = await LoroRepo.create({}); diff --git a/packages/components/tests/e2e/session-lifecycle-persistence.spec.ts b/packages/components/tests/e2e/session-lifecycle-persistence.spec.ts new file mode 100644 index 000000000..b10a8a94a --- /dev/null +++ b/packages/components/tests/e2e/session-lifecycle-persistence.spec.ts @@ -0,0 +1,123 @@ +import { expect, test } from '@playwright/test'; + +test('durably admits lifecycle records across connections, failures, and reload', async ({ + page, +}) => { + await page.goto( + '/iframe.html?id=infrastructure-sessionlifecyclepersistence--browser-harness&viewMode=story' + ); + await expect(page.getByTestId('session-lifecycle-persistence-ready')).toBeVisible(); + const testWorkspaceId = `playwright-${Date.now()}`; + + const first = await page.evaluate(async (evaluatedWorkspaceId) => { + const harness = ( + window as typeof window & { + __lodySessionLifecyclePersistence: { + createStore: typeof import('../../src/lib/session-lifecycle-persistence').createIndexedDbSessionLifecycleAdmissionStore; + }; + } + ).__lodySessionLifecyclePersistence; + const before = await harness.createStore({ + workspaceId: evaluatedWorkspaceId, + faults: { + beforeWrite: () => { + throw new Error('before write'); + }, + }, + }); + const makeDraft = (operationId: string) => ({ + operationId, + subjectId: 'root', + targetIds: ['root', 'child'], + state: 'archived' as const, + }); + let beforeError = ''; + try { + await before.admit(makeDraft('before'), 'browser-a', '0'); + } catch (error) { + beforeError = error instanceof Error ? error.message : String(error); + } + const missingBefore = (await before.get('before')) === undefined; + await before.close?.(); + + const after = await harness.createStore({ + workspaceId: evaluatedWorkspaceId, + faults: { + afterCommit: () => { + throw new Error('after commit'); + }, + }, + }); + let afterError = ''; + try { + await after.admit(makeDraft('after'), 'browser-a', '0'); + } catch (error) { + afterError = error instanceof Error ? error.message : String(error); + } + const durableAfter = await after.get('after'); + await after.close?.(); + return { beforeError, missingBefore, afterError, durableAfter }; + }, testWorkspaceId); + + expect(first).toMatchObject({ + beforeError: 'before write', + missingBefore: true, + afterError: 'after commit', + durableAfter: { + published: false, + operation: { operationId: 'after', order: { counter: '1', actorId: 'browser-a' } }, + }, + }); + + await page.reload(); + await expect(page.getByTestId('session-lifecycle-persistence-ready')).toBeVisible(); + const recovered = await page.evaluate(async (evaluatedWorkspaceId) => { + const harness = ( + window as typeof window & { + __lodySessionLifecyclePersistence: { + createStore: typeof import('../../src/lib/session-lifecycle-persistence').createIndexedDbSessionLifecycleAdmissionStore; + getDatabaseName: typeof import('../../src/lib/session-lifecycle-persistence').getSessionLifecycleIndexedDbName; + }; + } + ).__lodySessionLifecyclePersistence; + const primaryStore = await harness.createStore({ workspaceId: evaluatedWorkspaceId }); + const secondaryStore = await harness.createStore({ workspaceId: evaluatedWorkspaceId }); + const makeDraft = (operationId: string) => ({ + operationId, + subjectId: 'root', + targetIds: ['root', 'child'], + state: 'active' as const, + }); + const [one, two] = await Promise.all([ + primaryStore.admit(makeDraft('one'), 'browser-a', '40'), + secondaryStore.admit(makeDraft('two'), 'browser-b', '40'), + ]); + await primaryStore.markPublished('one'); + const rows = await secondaryStore.list(); + await primaryStore.close?.(); + await secondaryStore.close?.(); + const dbName = harness.getDatabaseName(evaluatedWorkspaceId); + const deleted = new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(dbName); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + await deleted; + return { counters: [one.operation.order.counter, two.operation.order.counter], rows }; + }, testWorkspaceId); + + expect(new Set(recovered.counters)).toEqual(new Set(['41', '42'])); + expect(recovered.rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ operation: expect.objectContaining({ operationId: 'after' }) }), + expect.objectContaining({ + operation: expect.objectContaining({ operationId: 'one' }), + published: true, + }), + expect.objectContaining({ + operation: expect.objectContaining({ operationId: 'two' }), + published: false, + }), + ]) + ); +}); diff --git a/packages/components/tests/session-lifecycle-persistence.test.ts b/packages/components/tests/session-lifecycle-persistence.test.ts new file mode 100644 index 000000000..3f014bbf3 --- /dev/null +++ b/packages/components/tests/session-lifecycle-persistence.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { + createIndexedDbSessionLifecycleAdmissionStore, + getSessionLifecycleIndexedDbName, +} from '../src/lib/session-lifecycle-persistence'; + +describe('IndexedDB Session lifecycle persistence boundary', () => { + it('uses one workspace-wide database name rather than a renderer cache namespace', () => { + expect(getSessionLifecycleIndexedDbName('workspace-a')).toBe( + 'lody-session-lifecycle-v1:workspace-a' + ); + }); + + it('fails closed when browser durability is unavailable', async () => { + await expect( + createIndexedDbSessionLifecycleAdmissionStore({ + workspaceId: 'workspace-a', + indexedDB: undefined, + }) + ).rejects.toThrow(/IndexedDB is unavailable/); + }); +}); diff --git a/packages/components/tests/use-session-actions.test.ts b/packages/components/tests/use-session-actions.test.ts index aa2be5754..f53c6d72b 100644 --- a/packages/components/tests/use-session-actions.test.ts +++ b/packages/components/tests/use-session-actions.test.ts @@ -3,12 +3,14 @@ import { act, createElement, useEffect } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { Provider, createStore } from 'jotai'; +import { LoroRepo } from 'loro-repo'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FREE_SESSION_LIMIT_PER_WORKSPACE, getMachineRoomId, getSessionRoomId, machineFlockKeys, + SessionLifecycleAdmissionUncertainError, type MachineId, type SessionId, type SessionMeta, @@ -122,7 +124,15 @@ function ActionsProbe({ onReady }: { onReady: (actions: SessionActions) => void const createRuntime = ( overrides: Partial< - Pick + Pick< + WorkspaceRuntime, + | 'ensureDocStream' + | 'repo' + | 'workspaceId' + | 'workspaceSlug' + | 'writer' + | 'sessionLifecycle' + > > ): WorkspaceRuntime => { const repo = @@ -151,6 +161,10 @@ const createRuntime = ( upsertDocMeta: vi.fn(async (roomId: string, patch: Record) => { await repoAsAny.upsertDocMeta?.(roomId, patch); }), + commitSessionLifecycle: vi.fn(async (draft) => { + if (!overrides.sessionLifecycle) throw new Error('lifecycle unavailable'); + return await overrides.sessionLifecycle.commit(draft); + }), startSession: vi.fn( async ( _sessionId: string, @@ -183,6 +197,7 @@ const createRuntime = ( workspaceSlug: overrides.workspaceSlug ?? 'workspace-slug', workspaceId: overrides.workspaceId ?? ('workspace-1' as WorkspaceId), repo, + sessionLifecycle: overrides.sessionLifecycle ?? null, writer, ensureDocStream: overrides.ensureDocStream ?? vi.fn(async () => undefined), releaseSessionStore: vi.fn(async () => undefined), @@ -257,6 +272,9 @@ function createSessionMetaRepo(sessions: readonly SessionMeta[]) { sessions.map((session) => [getSessionRoomId(session.id), { ...session }]) ); const repo = { + listDoc: vi.fn(async () => + Array.from(docs, ([docId, meta]) => ({ docId, exists: true, meta: { ...meta } })) + ), getDocMeta: vi.fn(async (roomId: string) => ({ meta: docs.get(roomId) ?? {} })), upsertDocMeta: vi.fn(async (roomId: string, patch: Record) => { docs.set(roomId, { ...(docs.get(roomId) ?? {}), ...patch }); @@ -313,9 +331,10 @@ describe('useSessionActions', () => { workspaceSlug?: string | null; docMetaCacheReady?: boolean; sessionMetaCache?: Record; + jotaiStore?: ReturnType; } = {} ): Promise => { - const jotaiStore = createStore(); + const jotaiStore = options.jotaiStore ?? createStore(); jotaiStore.set(runtimeAtom, runtime); jotaiStore.set(docMetaCacheReadyAtom, options.docMetaCacheReady ?? false); jotaiStore.set(sessionMetaCacheAtom, options.sessionMetaCache ?? {}); @@ -1122,6 +1141,9 @@ describe('useSessionActions', () => { const runtime = createRuntime({ repo: { getDocMeta, + listDoc: vi.fn(async () => [ + { docId: getSessionRoomId(sessionId), exists: true, meta: sessionMeta }, + ]), upsertDocMeta, openFlockDoc: vi.fn(async () => ({ flock: { scan: () => [], set: vi.fn(), delete: vi.fn(), commit: vi.fn() }, @@ -1142,7 +1164,7 @@ describe('useSessionActions', () => { expect(runtime.writer.flockRowPut).not.toHaveBeenCalled(); }); - it('archives from the rendered meta cache when repo meta has not hydrated', async () => { + it('rejects archive when only the rendered cache knows the session', async () => { const sessionId = 'session-archive-known-meta' as SessionId; const renderedMeta = { id: sessionId, @@ -1156,18 +1178,20 @@ describe('useSessionActions', () => { // The repo cannot read the doc meta yet (child session still hydrating). const getDocMeta = vi.fn(async () => undefined); const runtime = createRuntime({ - repo: { getDocMeta, upsertDocMeta } as unknown as WorkspaceRuntime['repo'], + repo: { + getDocMeta, + listDoc: vi.fn(async () => []), + upsertDocMeta, + } as unknown as WorkspaceRuntime['repo'], }); const actions = await renderActions(runtime, { sessionMetaCache: { [getSessionRoomId(sessionId)]: renderedMeta }, }); - await actions.archiveSession(sessionId); - - expect(upsertDocMeta).toHaveBeenCalledWith( - getSessionRoomId(sessionId), - expect.objectContaining({ isArchived: true }) + await expect(actions.archiveSession(sessionId)).rejects.toThrow( + `Session metadata missing for ${sessionId}` ); + expect(upsertDocMeta).not.toHaveBeenCalled(); // A session neither the repo nor the UI knows still fails loudly. await expect(actions.archiveSession('session-unknown-meta' as SessionId)).rejects.toThrow( @@ -1175,34 +1199,442 @@ describe('useSessionActions', () => { ); }); - it('archives child tabs without archiving independently opened session workspaces', async () => { + it('discovers child tabs through the production metadata scan before the UI cache hydrates', async () => { const { rootSession, tabSession, openedSession, openedFromTabSession, sessionMetaCache } = createContainmentSessions('archive', false); - const metaRepo = createSessionMetaRepo(Object.values(sessionMetaCache)); - const runtime = createRuntime({ repo: metaRepo.repo }); - const actions = await renderActions(runtime, { sessionMetaCache }); + const repo = await LoroRepo.create({}); + + try { + for (const session of Object.values(sessionMetaCache)) { + await repo.upsertDocMeta(getSessionRoomId(session.id), session); + } + const runtime = createRuntime({ repo }); + const actions = await renderActions(runtime, { + docMetaCacheReady: false, + sessionMetaCache: { [getSessionRoomId(rootSession.id)]: rootSession }, + }); + sendIpcMock.mockClear(); + + await actions.archiveSession(rootSession.id); + + for (const session of [rootSession, tabSession]) { + await expect(repo.getDocMeta(getSessionRoomId(session.id))).resolves.toMatchObject({ + meta: { isArchived: true, status: { type: 'idle' } }, + }); + } + for (const session of [openedSession, openedFromTabSession]) { + await expect(repo.getDocMeta(getSessionRoomId(session.id))).resolves.toMatchObject({ + meta: { isArchived: false }, + }); + } + + expect(sendIpcMock.mock.calls).toEqual([ + ['terminal.closeSession', { sessionId: rootSession.id }], + ['terminal.closeSession', { sessionId: tabSession.id }], + ]); + expect(runtime.writer.flockRowPut).not.toHaveBeenCalled(); + } finally { + await repo.destroy(); + } + }); + + it('commits one durable lifecycle operation before closing any selected terminal', async () => { + const { rootSession, tabSession, openedSession, sessions } = createContainmentSessions( + 'atomic-archive', + false + ); + const metaRepo = createSessionMetaRepo(sessions); + const commit = vi.fn(async (draft: Record) => ({ + operation: { + version: 1 as const, + ...draft, + order: { counter: '1', actorId: 'test' }, + }, + durability: 'accepted' as const, + publication: 'pending' as const, + revision: { revisionId: 'one', operationIds: ['operation'], bySessionId: new Map() }, + })); + const runtime = createRuntime({ + repo: metaRepo.repo, + sessionLifecycle: { commit } as unknown as NonNullable, + }); + const actions = await renderActions(runtime, { + sessionMetaCache: { [getSessionRoomId(rootSession.id)]: rootSession }, + }); sendIpcMock.mockClear(); await actions.archiveSession(rootSession.id); - for (const session of [rootSession, tabSession]) { - expect(metaRepo.getSession(session.id)).toMatchObject({ + expect(commit).toHaveBeenCalledOnce(); + expect(commit).toHaveBeenCalledWith({ + operationId: expect.any(String), + subjectId: rootSession.id, + targetIds: [rootSession.id, tabSession.id], + state: 'archived', + }); + expect(metaRepo.getSession(rootSession.id)).toMatchObject({ isArchived: false }); + expect(metaRepo.getSession(tabSession.id)).toMatchObject({ isArchived: false }); + expect(metaRepo.getSession(openedSession.id)).toMatchObject({ isArchived: false }); + expect(sendIpcMock.mock.calls).toEqual([ + ['terminal.closeSession', { sessionId: rootSession.id }], + ['terminal.closeSession', { sessionId: tabSession.id }], + ]); + }); + + it('leaves metadata and terminals untouched when lifecycle admission rejects', async () => { + const { rootSession, tabSession, sessions } = createContainmentSessions( + 'atomic-archive-reject', + false + ); + const metaRepo = createSessionMetaRepo(sessions); + const commit = vi.fn(async () => { + await metaRepo.repo.upsertDocMeta(getSessionRoomId(tabSession.id), { isArchived: true, - status: { type: 'idle' }, + status: { type: 'running' }, + thirdPartyField: 'kept', }); - } - for (const session of [openedSession, openedFromTabSession]) { - expect(metaRepo.getSession(session.id)).toMatchObject({ isArchived: false }); - } + throw new Error('durable admission rejected'); + }); + const runtime = createRuntime({ + repo: metaRepo.repo, + sessionLifecycle: { commit } as unknown as NonNullable, + }); + const actions = await renderActions(runtime, { + sessionMetaCache: { [getSessionRoomId(rootSession.id)]: rootSession }, + }); + sendIpcMock.mockClear(); + + await expect(actions.archiveSession(rootSession.id)).rejects.toThrow( + 'durable admission rejected' + ); + expect(metaRepo.getSession(rootSession.id)).toMatchObject({ isArchived: false }); + expect(metaRepo.getSession(tabSession.id)).toMatchObject({ + isArchived: true, + status: { type: 'running' }, + thirdPartyField: 'kept', + }); + expect(sendIpcMock).not.toHaveBeenCalled(); + }); + + it('reuses the same lifecycle operation id after an uncertain admission result', async () => { + const { rootSession, tabSession, sessions } = createContainmentSessions( + 'atomic-archive-retry', + false + ); + const metaRepo = createSessionMetaRepo(sessions); + const commit = vi + .fn() + .mockRejectedValueOnce(new SessionLifecycleAdmissionUncertainError('stable-retry')) + .mockResolvedValueOnce({ + operation: {}, + durability: 'accepted', + publication: 'pending', + revision: { revisionId: 'retry', operationIds: [], bySessionId: new Map() }, + }); + const runtime = createRuntime({ + repo: metaRepo.repo, + sessionLifecycle: { commit } as unknown as NonNullable, + }); + const actions = await renderActions(runtime); + + await expect(actions.archiveSession(rootSession.id)).rejects.toBeInstanceOf( + SessionLifecycleAdmissionUncertainError + ); + await actions.archiveSession(rootSession.id); + + expect(commit).toHaveBeenCalledTimes(2); + expect(commit.mock.calls[0]?.[0]).toMatchObject({ + operationId: commit.mock.calls[1]?.[0].operationId, + subjectId: rootSession.id, + targetIds: [rootSession.id, tabSession.id], + }); + }); + + it('uses complete repository discovery for an atomic restore while the UI cache is cold', async () => { + const { rootSession, tabSession, openedSession, sessions } = createContainmentSessions( + 'atomic-restore', + true + ); + const metaRepo = createSessionMetaRepo(sessions); + const commit = vi.fn(async (draft: Record) => ({ + operation: { + version: 1 as const, + ...draft, + order: { counter: '2', actorId: 'test' }, + }, + durability: 'accepted' as const, + publication: 'published' as const, + revision: { revisionId: 'two', operationIds: ['operation'], bySessionId: new Map() }, + })); + const runtime = createRuntime({ + repo: metaRepo.repo, + sessionLifecycle: { commit } as unknown as NonNullable, + }); + const actions = await renderActions(runtime, { + sessionMetaCache: { [getSessionRoomId(rootSession.id)]: rootSession }, + }); + + await actions.restoreSession(rootSession.id); + + expect(commit).toHaveBeenCalledWith({ + operationId: expect.any(String), + subjectId: rootSession.id, + targetIds: [rootSession.id, tabSession.id], + state: 'active', + }); + expect(commit).not.toHaveBeenCalledWith( + expect.objectContaining({ targetIds: expect.arrayContaining([openedSession.id]) }) + ); + }); + + it('keeps the root active and compensates child writes when a later child write fails', async () => { + const { rootSession, tabSession, sessions } = createContainmentSessions( + 'archive-child-failure', + false + ); + const secondTabSession = { + ...tabSession, + id: 'archive-child-failure-tab-2' as SessionId, + createdAt: '2026-08-24T00:01:30.000Z', + } as SessionMeta; + tabSession.status = { type: 'running' }; + secondTabSession.status = { type: 'requestPermission' }; + const metaRepo = createSessionMetaRepo([...sessions, secondTabSession]); + const runtime = createRuntime({ repo: metaRepo.repo }); + const upsertDocMeta = vi.mocked(runtime.writer.upsertDocMeta); + let rejectedChildWrite = false; + upsertDocMeta.mockImplementation(async (roomId, patch) => { + if ( + roomId === getSessionRoomId(secondTabSession.id) && + patch.isArchived === true && + !rejectedChildWrite + ) { + rejectedChildWrite = true; + throw new Error('child archive failed'); + } + await metaRepo.repo.upsertDocMeta(roomId, patch); + }); + const actions = await renderActions(runtime, { + sessionMetaCache: { [getSessionRoomId(rootSession.id)]: rootSession }, + }); + sendIpcMock.mockClear(); + + await expect(actions.archiveSession(rootSession.id)).rejects.toThrow('child archive failed'); + expect(metaRepo.getSession(rootSession.id)).toMatchObject({ isArchived: false }); + expect(metaRepo.getSession(tabSession.id)).toMatchObject({ + isArchived: false, + status: { type: 'running' }, + }); + expect(metaRepo.getSession(secondTabSession.id)).toMatchObject({ + isArchived: false, + status: { type: 'requestPermission' }, + }); + expect(upsertDocMeta.mock.calls.map(([roomId, patch]) => [roomId, patch.isArchived])).toEqual([ + [getSessionRoomId(tabSession.id), true], + [getSessionRoomId(secondTabSession.id), true], + [getSessionRoomId(secondTabSession.id), false], + [getSessionRoomId(tabSession.id), false], + ]); + expect(sendIpcMock).not.toHaveBeenCalled(); + }); + + it('compensates children and closes no terminals when the final root write fails', async () => { + const { rootSession, tabSession, sessions } = createContainmentSessions( + 'archive-root-failure', + false + ); + const metaRepo = createSessionMetaRepo(sessions); + rootSession.status = { type: 'running' }; + tabSession.status = { type: 'requestPermission' }; + metaRepo.setMeta(getSessionRoomId(rootSession.id), rootSession); + metaRepo.setMeta(getSessionRoomId(tabSession.id), tabSession); + const runtime = createRuntime({ repo: metaRepo.repo }); + const upsertDocMeta = vi.mocked(runtime.writer.upsertDocMeta); + let rejectedRootWrite = false; + upsertDocMeta.mockImplementation(async (roomId, patch) => { + if ( + roomId === getSessionRoomId(rootSession.id) && + patch.isArchived === true && + !rejectedRootWrite + ) { + rejectedRootWrite = true; + throw new Error('root archive failed'); + } + await metaRepo.repo.upsertDocMeta(roomId, patch); + }); + const actions = await renderActions(runtime, { + sessionMetaCache: { [getSessionRoomId(rootSession.id)]: rootSession }, + }); + sendIpcMock.mockClear(); + + await expect(actions.archiveSession(rootSession.id)).rejects.toThrow('root archive failed'); + + expect(metaRepo.getSession(rootSession.id)).toMatchObject({ + isArchived: false, + status: { type: 'running' }, + }); + expect(metaRepo.getSession(tabSession.id)).toMatchObject({ + isArchived: false, + status: { type: 'requestPermission' }, + }); + expect(upsertDocMeta.mock.calls.map(([roomId, patch]) => [roomId, patch.isArchived])).toEqual([ + [getSessionRoomId(tabSession.id), true], + [getSessionRoomId(rootSession.id), true], + [getSessionRoomId(rootSession.id), false], + [getSessionRoomId(tabSession.id), false], + ]); + expect(sendIpcMock).not.toHaveBeenCalled(); + }); + + it('keeps children archived when an accepted root write and its compensation both fail', async () => { + const { rootSession, tabSession, sessions } = createContainmentSessions( + 'archive-root-rollback-failure', + false + ); + const metaRepo = createSessionMetaRepo(sessions); + const runtime = createRuntime({ repo: metaRepo.repo }); + const upsertDocMeta = vi.mocked(runtime.writer.upsertDocMeta); + upsertDocMeta.mockImplementation(async (roomId, patch) => { + if (roomId === getSessionRoomId(rootSession.id) && patch.isArchived === false) { + throw new Error('root rollback failed'); + } + await metaRepo.repo.upsertDocMeta(roomId, patch); + if (roomId === getSessionRoomId(rootSession.id) && patch.isArchived === true) { + throw new Error('root archive acknowledgement failed'); + } + }); + const actions = await renderActions(runtime, { + sessionMetaCache: { [getSessionRoomId(rootSession.id)]: rootSession }, + }); + sendIpcMock.mockClear(); + + const failure = await actions.archiveSession(rootSession.id).catch((error: unknown) => error); + + expect(failure).toMatchObject({ + message: 'Archive failed and 1 lifecycle rollback(s) also failed', + cause: { message: 'root archive acknowledgement failed' }, + compensationFailures: [{ message: 'root rollback failed' }], + }); + expect(metaRepo.getSession(rootSession.id)).toMatchObject({ isArchived: true }); + expect(metaRepo.getSession(tabSession.id)).toMatchObject({ isArchived: true }); + expect(upsertDocMeta.mock.calls.map(([roomId, patch]) => [roomId, patch.isArchived])).toEqual([ + [getSessionRoomId(tabSession.id), true], + [getSessionRoomId(rootSession.id), true], + [getSessionRoomId(rootSession.id), false], + ]); + expect(sendIpcMock).not.toHaveBeenCalled(); + }); + + it('fails archive without writes when the repository metadata snapshot cannot be read', async () => { + const sessionId = 'archive-scan-failure' as SessionId; + const sessionMeta = { + id: sessionId, + machineId: 'archive-scan-failure-machine' as MachineId, + isArchived: false, + createdAt: '2026-09-13T00:00:00.000Z', + } as SessionMeta; + const upsertDocMeta = vi.fn(async () => undefined); + const runtime = createRuntime({ + repo: { + listDoc: vi.fn(async () => { + throw new Error('metadata scan failed'); + }), + upsertDocMeta, + } as unknown as WorkspaceRuntime['repo'], + }); + const actions = await renderActions(runtime, { + sessionMetaCache: { [getSessionRoomId(sessionId)]: sessionMeta }, + }); + + await expect(actions.archiveSession(sessionId)).rejects.toThrow('metadata scan failed'); + expect(upsertDocMeta).not.toHaveBeenCalled(); + }); + + it('fails archive without writes when the workspace changes during metadata discovery', async () => { + const sessionId = 'archive-runtime-change' as SessionId; + const sessionMeta = { + id: sessionId, + machineId: 'archive-runtime-change-machine' as MachineId, + isArchived: false, + createdAt: '2026-09-13T00:00:00.000Z', + } as SessionMeta; + let resolveListDoc!: ( + entries: Array<{ docId: string; exists: boolean; meta: SessionMeta }> + ) => void; + const listDocResult = new Promise>( + (resolve) => { + resolveListDoc = resolve; + } + ); + const upsertDocMeta = vi.fn(async () => undefined); + const runtime = createRuntime({ + repo: { + listDoc: vi.fn(() => listDocResult), + upsertDocMeta, + } as unknown as WorkspaceRuntime['repo'], + }); + const jotaiStore = createStore(); + const actions = await renderActions(runtime, { + jotaiStore, + sessionMetaCache: { [getSessionRoomId(sessionId)]: sessionMeta }, + }); + + const archivePromise = actions.archiveSession(sessionId); + const archiveRejection = expect(archivePromise).rejects.toThrow( + 'Workspace changed while loading session metadata' + ); + await act(async () => { + jotaiStore.set(runtimeAtom, createRuntime({ workspaceId: 'workspace-2' as WorkspaceId })); + resolveListDoc([{ docId: getSessionRoomId(sessionId), exists: true, meta: sessionMeta }]); + }); + + await archiveRejection; + expect(upsertDocMeta).not.toHaveBeenCalled(); + }); + + it('finishes the captured workspace commit after the first archive write starts', async () => { + const { rootSession, tabSession, sessions } = createContainmentSessions( + 'archive-runtime-commit', + false + ); + const metaRepo = createSessionMetaRepo(sessions); + const jotaiStore = createStore(); + const nextRuntimeUpsert = vi.fn(async () => undefined); + const nextRuntime = createRuntime({ + workspaceId: 'workspace-2' as WorkspaceId, + repo: { + upsertDocMeta: nextRuntimeUpsert, + } as unknown as WorkspaceRuntime['repo'], + }); + const runtime = createRuntime({ repo: metaRepo.repo }); + const upsertDocMeta = vi.mocked(runtime.writer.upsertDocMeta); + let archiveWriteCount = 0; + upsertDocMeta.mockImplementation(async (roomId, patch) => { + await metaRepo.repo.upsertDocMeta(roomId, patch); + if (patch.isArchived === true && ++archiveWriteCount === 1) { + jotaiStore.set(runtimeAtom, nextRuntime); + } + }); + const actions = await renderActions(runtime, { + jotaiStore, + sessionMetaCache: { [getSessionRoomId(rootSession.id)]: rootSession }, + }); + sendIpcMock.mockClear(); + + await actions.archiveSession(rootSession.id); + + expect(metaRepo.getSession(rootSession.id)).toMatchObject({ isArchived: true }); + expect(metaRepo.getSession(tabSession.id)).toMatchObject({ isArchived: true }); + expect(upsertDocMeta.mock.calls.map(([roomId, patch]) => [roomId, patch.isArchived])).toEqual([ + [getSessionRoomId(tabSession.id), true], + [getSessionRoomId(rootSession.id), true], + ]); + expect(nextRuntimeUpsert).not.toHaveBeenCalled(); expect(sendIpcMock.mock.calls).toEqual([ ['terminal.closeSession', { sessionId: rootSession.id }], ['terminal.closeSession', { sessionId: tabSession.id }], ]); - expect(runtime.writer.flockRowPut).not.toHaveBeenCalled(); - for (const session of [rootSession, openedSession, openedFromTabSession]) { - expect(metaRepo.getMeta(getMachineRoomId(session.machineId))).toBeUndefined(); - } }); it('restores child tabs without restoring independently opened session workspaces', async () => { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b6b91c226..aeb6a6453 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -78,6 +78,8 @@ export * from './repo-doc-meta'; export * from './session-input'; export * from './session-preparation'; export * from './session-bootstrap'; +export * from './session-lifecycle'; +export * from './session-lifecycle-repository'; export * from './goal'; export * from './comment-reference-format'; export * from './session-comment-types'; diff --git a/packages/shared/src/session-lifecycle-repository.ts b/packages/shared/src/session-lifecycle-repository.ts new file mode 100644 index 000000000..f022fa9df --- /dev/null +++ b/packages/shared/src/session-lifecycle-repository.ts @@ -0,0 +1,588 @@ +import type { SessionId } from './ids'; +import type { JsonObject, LoroRepo } from 'loro-repo'; +import { + canonicalizeSessionLifecycleOperation, + compareSessionLifecycleOrder, + encodeSessionLifecycleOperation, + parseSessionLifecycleOperation, + resolveSessionLifecycleRevision, + sessionLifecycleOperationsEqual, + SessionLifecycleOperationConflictError, + SessionLifecycleProtocolError, + type SessionLifecycleOperation, + type SessionLifecycleRevision, + type SessionLifecycleState, +} from './session-lifecycle'; + +export const SESSION_LIFECYCLE_OPERATION_DOC_ID = '_lody/session-lifecycle-operations/v1'; +export const SESSION_LIFECYCLE_OPERATION_FIELD_PREFIX = 'operation:'; +const SESSION_LIFECYCLE_REPOSITORY_SYMBOL = Symbol.for('lody.sessionLifecycleRepository.v1'); + +export type SessionLifecycleOperationDraft = { + operationId: string; + subjectId: SessionId; + targetIds: readonly SessionId[]; + state: SessionLifecycleState; +}; + +export type SessionLifecycleAdmission = { + operation: SessionLifecycleOperation; + published: boolean; +}; + +export interface SessionLifecycleAdmissionStore { + list(): Promise; + get(operationId: string): Promise; + /** + * Atomically return an existing identical admission or allocate and persist a + * counter above both the store high-water mark and observedCounter. + */ + admit( + draft: SessionLifecycleOperationDraft, + actorId: string, + observedCounter: string + ): Promise; + /** Insert an exact counter-zero migration baseline without allocating order. */ + seed(operation: SessionLifecycleOperation): Promise; + observeCounter(counter: string): Promise; + markPublished(operationId: string): Promise; + close?(): Promise; +} + +export interface SessionLifecycleOperationPublisher { + list(): Promise; + publish(operation: SessionLifecycleOperation): Promise; + subscribe(listener: (operation: unknown) => void): () => void; +} + +export type SessionLifecycleCommitReceipt = { + operation: SessionLifecycleOperation; + durability: 'accepted'; + publication: 'published' | 'pending'; + revision: SessionLifecycleRevision; +}; + +export type SessionLifecycleRevisionEvent = { + revision: SessionLifecycleRevision; + previous: SessionLifecycleRevision; + source: 'local' | 'remote' | 'recovery'; +}; + +export class SessionLifecycleAdmissionUncertainError extends Error { + constructor( + readonly operationId: string, + options: { cause?: unknown } = {} + ) { + super(`Lifecycle admission outcome is uncertain for ${operationId}`, options); + this.name = 'SessionLifecycleAdmissionUncertainError'; + } +} + +export class SessionLifecycleAdmissionRejectedError extends Error { + constructor(message: string, options: { cause?: unknown } = {}) { + super(message, options); + this.name = 'SessionLifecycleAdmissionRejectedError'; + } +} + +function operationMatchesDraft( + operation: SessionLifecycleOperation, + draft: SessionLifecycleOperationDraft +): boolean { + return sessionLifecycleOperationsEqual( + operation, + canonicalizeSessionLifecycleOperation({ + version: 1, + ...draft, + order: operation.order, + }) + ); +} + +function highestCounter(operations: Iterable): string { + let highest = 0n; + for (const operation of operations) { + const counter = BigInt(operation.order.counter); + if (counter > highest) highest = counter; + } + return highest.toString(10); +} + +export class SessionLifecycleRepository { + private readonly operations = new Map(); + private readonly listeners = new Set<(event: SessionLifecycleRevisionEvent) => void>(); + private revision: SessionLifecycleRevision = resolveSessionLifecycleRevision([]); + private commandQueue: Promise = Promise.resolve(); + private unsubscribePublisher: (() => void) | null = null; + private initialized = false; + private disposed = false; + private protocolFailure: unknown = null; + + constructor( + private readonly options: { + actorId: string; + store: SessionLifecycleAdmissionStore; + publisher: SessionLifecycleOperationPublisher; + } + ) { + if (!options.actorId) throw new SessionLifecycleProtocolError('actorId must be non-empty'); + } + + async initialize( + options: { baselines?: readonly SessionLifecycleOperation[] } = {} + ): Promise { + if (this.initialized) return; + if (this.disposed) throw new Error('Session lifecycle repository is disposed'); + + const queuedRemote: unknown[] = []; + this.unsubscribePublisher = this.options.publisher.subscribe((operation) => { + if (!this.initialized) { + queuedRemote.push(operation); + return; + } + void this.enqueue(async () => { + try { + await this.acceptObserved(operation, 'remote'); + } catch (error) { + this.protocolFailure = error; + throw error; + } + }).catch(() => undefined); + }); + + try { + const [published, admissions] = await Promise.all([ + this.options.publisher.list(), + this.options.store.list(), + ]); + for (const value of published) this.addOperation(parseSessionLifecycleOperation(value)); + for (const admission of admissions) this.addOperation(admission.operation); + const seeded: SessionLifecycleAdmission[] = []; + for (const baseline of options.baselines ?? []) { + if (baseline.order.counter !== '0' || baseline.order.actorId !== 'baseline:v1') { + throw new SessionLifecycleProtocolError( + 'Migration baselines must use baseline:v1 at counter zero' + ); + } + const admission = await this.options.store.seed(baseline); + this.addOperation(admission.operation); + seeded.push(admission); + } + // A publisher can deliver operations while baseline seeding or high-water + // persistence is awaiting I/O. Drain until no delivery arrived during the + // preceding await, then make the repository ready without another yield. + for (;;) { + for (const value of queuedRemote.splice(0)) { + this.addOperation(parseSessionLifecycleOperation(value)); + } + await this.options.store.observeCounter(highestCounter(this.operations.values())); + if (queuedRemote.length === 0) break; + } + this.replaceRevision('recovery'); + this.initialized = true; + // Admission is the success boundary. A publisher outage leaves these rows + // pending for reconnect/restart replay and must not make startup unusable. + await this.publishPending([...admissions, ...seeded]).catch(() => undefined); + } catch (error) { + this.unsubscribePublisher?.(); + this.unsubscribePublisher = null; + throw error; + } + } + + getRevision(): SessionLifecycleRevision { + return this.revision; + } + + getOperation(operationId: string): SessionLifecycleOperation | undefined { + return this.operations.get(operationId); + } + + subscribe(listener: (event: SessionLifecycleRevisionEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + commit(draft: SessionLifecycleOperationDraft): Promise { + return this.enqueue(async () => { + this.assertReady(); + const parsedDraft = canonicalizeSessionLifecycleOperation({ + version: 1, + ...draft, + order: { counter: '0', actorId: this.options.actorId }, + }); + const normalizedDraft: SessionLifecycleOperationDraft = { + operationId: parsedDraft.operationId, + subjectId: parsedDraft.subjectId, + targetIds: parsedDraft.targetIds, + state: parsedDraft.state, + }; + + let admission: SessionLifecycleAdmission; + try { + admission = await this.options.store.admit( + normalizedDraft, + this.options.actorId, + highestCounter(this.operations.values()) + ); + } catch (cause) { + let recovered: SessionLifecycleAdmission | undefined; + try { + recovered = await this.options.store.get(normalizedDraft.operationId); + } catch { + throw new SessionLifecycleAdmissionUncertainError(normalizedDraft.operationId, { cause }); + } + if (!recovered && cause instanceof SessionLifecycleAdmissionRejectedError) throw cause; + if (!recovered) { + throw new SessionLifecycleAdmissionUncertainError(normalizedDraft.operationId, { cause }); + } + admission = recovered; + } + if (!operationMatchesDraft(admission.operation, normalizedDraft)) { + throw new SessionLifecycleOperationConflictError(normalizedDraft.operationId); + } + + this.addOperation(admission.operation); + this.replaceRevision('local'); + let publication: SessionLifecycleCommitReceipt['publication'] = admission.published + ? 'published' + : 'pending'; + if (!admission.published) { + try { + await this.options.publisher.publish(admission.operation); + await this.options.store.markPublished(admission.operation.operationId); + publication = 'published'; + } catch { + publication = 'pending'; + } + } + return { + operation: admission.operation, + durability: 'accepted', + publication, + revision: this.revision, + }; + }); + } + + async flushPending(): Promise { + await this.enqueue(async () => { + this.assertReady(); + await this.publishPending(await this.options.store.list()); + }); + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + this.unsubscribePublisher?.(); + this.unsubscribePublisher = null; + await this.commandQueue.catch(() => undefined); + await this.options.store.close?.(); + this.listeners.clear(); + } + + private enqueue(work: () => Promise): Promise { + const result = this.commandQueue.then(work, work); + this.commandQueue = result.then( + () => undefined, + () => undefined + ); + return result; + } + + private assertReady(): void { + if (!this.initialized) throw new Error('Session lifecycle repository is not initialized'); + if (this.disposed) throw new Error('Session lifecycle repository is disposed'); + if (this.protocolFailure) { + throw new SessionLifecycleProtocolError( + `Session lifecycle repository rejected a replicated operation: ${ + this.protocolFailure instanceof Error + ? this.protocolFailure.message + : String(this.protocolFailure) + }` + ); + } + } + + private addOperation(value: SessionLifecycleOperation): void { + const operation = canonicalizeSessionLifecycleOperation(value); + const existing = this.operations.get(operation.operationId); + if (existing && !sessionLifecycleOperationsEqual(existing, operation)) { + throw new SessionLifecycleOperationConflictError(operation.operationId); + } + this.operations.set(operation.operationId, operation); + } + + private async acceptObserved(value: unknown, source: 'remote' | 'recovery'): Promise { + const operation = parseSessionLifecycleOperation(value); + await this.options.store.observeCounter(operation.order.counter); + this.addOperation(operation); + this.replaceRevision(source); + } + + private replaceRevision(source: SessionLifecycleRevisionEvent['source']): void { + const previous = this.revision; + const revision = resolveSessionLifecycleRevision(this.operations.values()); + if (revision.revisionId === previous.revisionId) return; + this.revision = revision; + for (const listener of this.listeners) { + try { + listener({ revision, previous, source }); + } catch { + // Projection consumers cannot invalidate an already durable admission. + } + } + } + + private async publishPending(admissions: readonly SessionLifecycleAdmission[]): Promise { + const pending = admissions + .filter((admission) => !admission.published) + .sort((left, right) => compareSessionLifecycleOrder(left.operation, right.operation)); + for (const admission of pending) { + await this.options.publisher.publish(admission.operation); + await this.options.store.markPublished(admission.operation.operationId); + } + } +} + +export function createSessionLifecycleBaselineOperation( + sessionId: SessionId +): SessionLifecycleOperation { + return { + version: 1, + operationId: `baseline:v1:${sessionId}`, + subjectId: sessionId, + targetIds: [sessionId], + state: 'archived', + order: { counter: '0', actorId: 'baseline:v1' }, + }; +} + +export function projectSessionLifecycleMetadata( + repository: SessionLifecycleRepository, + sessionId: SessionId, + metadata: Record +): Record { + const winner = repository.getRevision().bySessionId.get(sessionId); + return { ...metadata, isArchived: winner?.state === 'archived' }; +} + +type RepoMetaRecord = { readonly meta: JsonObject; readonly deleted: boolean }; +type ProjectableRepoFilter = { + docIds?: readonly string[]; + kinds?: readonly string[]; + metadataFields?: readonly string[]; + by?: readonly string[]; +}; +type ProjectableRepo = Pick< + LoroRepo, + 'getDocMeta' | 'getDocMetaMany' | 'listDoc' | 'upsertDocMeta' | 'watch' +> & { + [SESSION_LIFECYCLE_REPOSITORY_SYMBOL]?: SessionLifecycleRepository; +}; + +export function getAttachedSessionLifecycleRepository( + repo: object +): SessionLifecycleRepository | null { + return ( + (repo as { [SESSION_LIFECYCLE_REPOSITORY_SYMBOL]?: SessionLifecycleRepository })[ + SESSION_LIFECYCLE_REPOSITORY_SYMBOL + ] ?? null + ); +} + +function filterAcceptsLifecycleEvent( + filter: ProjectableRepoFilter | undefined, + docId: string, + by: string +): boolean { + if (filter?.kinds && !filter.kinds.includes('doc-metadata')) return false; + if (filter?.docIds && !filter.docIds.includes(docId)) return false; + if (filter?.metadataFields && !filter.metadataFields.includes('isArchived')) return false; + if (filter?.by && !filter.by.includes(by)) return false; + return true; +} + +/** + * Install the lifecycle overlay at the repository read/watch seam. The operation + * revision is replaced before any synthesized per-session event is delivered, + * so a listener that rereads any target observes the complete new revision. + */ +export function installSessionLifecycleRepoProjection(options: { + repo: ProjectableRepo; + repository: SessionLifecycleRepository; + getSessionId(docId: string): SessionId | null; + getSessionDocId(sessionId: SessionId): string; + rejectLegacyWrites?: boolean; +}): void { + const { repo, repository, getSessionId, getSessionDocId } = options; + if (repo[SESSION_LIFECYCLE_REPOSITORY_SYMBOL]) { + throw new Error('Session lifecycle projection is already installed'); + } + const rawGetDocMeta = repo.getDocMeta.bind(repo); + const rawGetDocMetaMany = repo.getDocMetaMany?.bind(repo); + const rawListDoc = repo.listDoc.bind(repo); + const rawUpsertDocMeta = repo.upsertDocMeta.bind(repo); + const rawWatch = repo.watch.bind(repo); + repo[SESSION_LIFECYCLE_REPOSITORY_SYMBOL] = repository; + + const projectRecord = ( + docId: string, + record: T | undefined + ): T | undefined => { + const sessionId = getSessionId(docId); + if (!record || !sessionId || record.deleted === true) return record; + return { + ...record, + meta: projectSessionLifecycleMetadata(repository, sessionId, record.meta) as JsonObject, + }; + }; + + repo.getDocMeta = async (docId) => projectRecord(docId, await rawGetDocMeta(docId)); + if (rawGetDocMetaMany) { + repo.getDocMetaMany = async (docIds) => { + const records = await rawGetDocMetaMany(docIds); + const projected = new Map(); + for (const [docId, record] of records) { + const next = projectRecord(docId, record); + if (next) projected.set(docId, next); + } + return projected; + }; + } + repo.listDoc = async (query) => + (await rawListDoc(query)).map((record) => projectRecord(record.docId, record) ?? record); + + repo.upsertDocMeta = async (docId, patch) => { + const sessionId = getSessionId(docId); + if (options.rejectLegacyWrites && sessionId && Object.hasOwn(patch, 'isArchived')) { + const isInitialization = + patch.isArchived === false && !repository.getRevision().bySessionId.has(sessionId); + if (!isInitialization) { + throw new SessionLifecycleProtocolError( + `Direct isArchived writes are disabled after lifecycle activation for ${sessionId}` + ); + } + } + return rawUpsertDocMeta(docId, patch); + }; + + repo.watch = (listener, filter) => { + const rawHandle = rawWatch((event) => { + if ( + event.kind !== 'doc-metadata' || + !event.patch || + !Object.hasOwn(event.patch, 'isArchived') + ) { + listener(event); + return; + } + const { isArchived: _ignored, ...remainingPatch } = event.patch; + void _ignored; + if (Object.keys(remainingPatch).length > 0) listener({ ...event, patch: remainingPatch }); + }, filter); + const unsubscribeRevision = repository.subscribe(({ revision, previous, source }) => { + const targetIds = new Set([...previous.bySessionId.keys(), ...revision.bySessionId.keys()]); + const by = source === 'local' ? 'local' : 'live'; + for (const sessionId of targetIds) { + const previousArchived = previous.bySessionId.get(sessionId)?.state === 'archived'; + const nextArchived = revision.bySessionId.get(sessionId)?.state === 'archived'; + if (previousArchived === nextArchived) continue; + const docId = getSessionDocId(sessionId); + if (!filterAcceptsLifecycleEvent(filter, docId, by)) continue; + listener({ kind: 'doc-metadata', docId, patch: { isArchived: nextArchived }, by }); + } + }); + return { + unsubscribe() { + rawHandle.unsubscribe(); + unsubscribeRevision(); + }, + }; + }; +} + +type RepoWatchEvent = { + kind: string; + docId?: string; + patch?: Record; +}; + +type LifecycleMetaRepo = { + getDocMeta(docId: string): Promise<{ meta?: unknown } | undefined>; + upsertDocMeta(docId: string, patch: Record): Promise; + persistMetaNow(): Promise; + watch( + listener: (event: RepoWatchEvent) => void, + filter?: Record + ): { + unsubscribe(): void; + }; +}; + +function decodePublishedOperation(value: unknown): SessionLifecycleOperation { + if (typeof value !== 'string') { + throw new SessionLifecycleProtocolError('Published lifecycle operation must be JSON text'); + } + try { + return parseSessionLifecycleOperation(JSON.parse(value)); + } catch (error) { + if (error instanceof SessionLifecycleProtocolError) throw error; + throw new SessionLifecycleProtocolError('Published lifecycle operation contains invalid JSON'); + } +} + +export function createLoroMetaSessionLifecyclePublisher( + repo: LifecycleMetaRepo +): SessionLifecycleOperationPublisher { + return { + async list() { + const record = await repo.getDocMeta(SESSION_LIFECYCLE_OPERATION_DOC_ID); + if (!record?.meta || typeof record.meta !== 'object' || Array.isArray(record.meta)) return []; + return Object.entries(record.meta as Record) + .filter(([field]) => field.startsWith(SESSION_LIFECYCLE_OPERATION_FIELD_PREFIX)) + .map(([, value]) => decodePublishedOperation(value)); + }, + async publish(operation) { + const field = `${SESSION_LIFECYCLE_OPERATION_FIELD_PREFIX}${operation.operationId}`; + const existing = await repo.getDocMeta(SESSION_LIFECYCLE_OPERATION_DOC_ID); + const existingValue = + existing?.meta && typeof existing.meta === 'object' && !Array.isArray(existing.meta) + ? (existing.meta as Record)[field] + : undefined; + if (existingValue !== undefined) { + const published = decodePublishedOperation(existingValue); + if (!sessionLifecycleOperationsEqual(published, operation)) { + throw new SessionLifecycleOperationConflictError(operation.operationId); + } + return; + } + await repo.upsertDocMeta(SESSION_LIFECYCLE_OPERATION_DOC_ID, { + [field]: encodeSessionLifecycleOperation(operation), + }); + await repo.persistMetaNow(); + }, + subscribe(listener) { + const handle = repo.watch((event) => { + if (event.kind !== 'doc-metadata' || event.docId !== SESSION_LIFECYCLE_OPERATION_DOC_ID) { + return; + } + for (const [field, value] of Object.entries(event.patch ?? {})) { + if (!field.startsWith(SESSION_LIFECYCLE_OPERATION_FIELD_PREFIX) || value === null) + continue; + if (typeof value !== 'string') { + listener(value); + continue; + } + try { + listener(JSON.parse(value)); + } catch { + listener(value); + } + } + }); + return () => handle.unsubscribe(); + }, + }; +} diff --git a/packages/shared/src/session-lifecycle.ts b/packages/shared/src/session-lifecycle.ts new file mode 100644 index 000000000..9027d973c --- /dev/null +++ b/packages/shared/src/session-lifecycle.ts @@ -0,0 +1,212 @@ +import type { SessionId } from './ids'; + +export const SESSION_LIFECYCLE_VERSION = 1 as const; + +export type SessionLifecycleState = 'archived' | 'active'; + +export type SessionLifecycleOrder = { + counter: string; + actorId: string; +}; + +export type SessionLifecycleOperation = { + version: typeof SESSION_LIFECYCLE_VERSION; + operationId: string; + subjectId: SessionId; + targetIds: readonly SessionId[]; + state: SessionLifecycleState; + order: SessionLifecycleOrder; +}; + +export type SessionLifecycleWinner = { + operationId: string; + state: SessionLifecycleState; + order: SessionLifecycleOrder; +}; + +export type SessionLifecycleRevision = { + /** Stable, canonical identity. It is intentionally not a wall-clock value. */ + revisionId: string; + operationIds: readonly string[]; + bySessionId: ReadonlyMap; +}; + +export class SessionLifecycleProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = 'SessionLifecycleProtocolError'; + } +} +export class SessionLifecycleOperationConflictError extends SessionLifecycleProtocolError { + constructor(readonly operationId: string) { + super(`Conflicting payloads use lifecycle operation id ${operationId}`); + this.name = 'SessionLifecycleOperationConflictError'; + } +} + +const CANONICAL_COUNTER_PATTERN = /^(0|[1-9][0-9]*)$/; + +function assertNonEmptyString(value: unknown, field: string): asserts value is string { + if (typeof value !== 'string' || value.length === 0) { + throw new SessionLifecycleProtocolError(`${field} must be a non-empty string`); + } +} + +function compareUtf8(left: string, right: string): number { + const encoder = new TextEncoder(); + const leftBytes = encoder.encode(left); + const rightBytes = encoder.encode(right); + const length = Math.min(leftBytes.length, rightBytes.length); + for (let index = 0; index < length; index += 1) { + const difference = (leftBytes[index] ?? 0) - (rightBytes[index] ?? 0); + if (difference !== 0) return difference; + } + return leftBytes.length - rightBytes.length; +} + +export function compareSessionLifecycleOrder( + left: Pick, + right: Pick +): number { + const leftCounter = BigInt(left.order.counter); + const rightCounter = BigInt(right.order.counter); + if (leftCounter < rightCounter) return -1; + if (leftCounter > rightCounter) return 1; + const actorOrder = compareUtf8(left.order.actorId, right.order.actorId); + return actorOrder === 0 ? compareUtf8(left.operationId, right.operationId) : actorOrder; +} + +export function parseSessionLifecycleOperation(value: unknown): SessionLifecycleOperation { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new SessionLifecycleProtocolError('Lifecycle operation must be an object'); + } + const input = value as Record; + if (input.version !== SESSION_LIFECYCLE_VERSION) { + throw new SessionLifecycleProtocolError(`Unsupported lifecycle operation version ${String(input.version)}`); + } + assertNonEmptyString(input.operationId, 'operationId'); + assertNonEmptyString(input.subjectId, 'subjectId'); + if (input.state !== 'archived' && input.state !== 'active') { + throw new SessionLifecycleProtocolError('state must be archived or active'); + } + if (!Array.isArray(input.targetIds) || input.targetIds.length === 0) { + throw new SessionLifecycleProtocolError('targetIds must be a non-empty array'); + } + const targetIds = input.targetIds.map((targetId, index) => { + assertNonEmptyString(targetId, `targetIds[${index}]`); + return targetId as SessionId; + }); + if (new Set(targetIds).size !== targetIds.length) { + throw new SessionLifecycleProtocolError('targetIds must not contain duplicates'); + } + if (!targetIds.includes(input.subjectId as SessionId)) { + throw new SessionLifecycleProtocolError('targetIds must include subjectId'); + } + if (typeof input.order !== 'object' || input.order === null || Array.isArray(input.order)) { + throw new SessionLifecycleProtocolError('order must be an object'); + } + const order = input.order as Record; + assertNonEmptyString(order.counter, 'order.counter'); + if (!CANONICAL_COUNTER_PATTERN.test(order.counter)) { + throw new SessionLifecycleProtocolError('order.counter must be canonical non-negative decimal'); + } + assertNonEmptyString(order.actorId, 'order.actorId'); + + return { + version: SESSION_LIFECYCLE_VERSION, + operationId: input.operationId, + subjectId: input.subjectId as SessionId, + targetIds, + state: input.state, + order: { counter: order.counter, actorId: order.actorId }, + }; +} + +export function canonicalizeSessionLifecycleOperation( + operation: SessionLifecycleOperation +): SessionLifecycleOperation { + const parsed = parseSessionLifecycleOperation(operation); + return { + ...parsed, + targetIds: [...parsed.targetIds].sort(compareUtf8), + }; +} + +export function encodeSessionLifecycleOperation(operation: SessionLifecycleOperation): string { + const canonical = canonicalizeSessionLifecycleOperation(operation); + return JSON.stringify({ + version: canonical.version, + operationId: canonical.operationId, + subjectId: canonical.subjectId, + targetIds: canonical.targetIds, + state: canonical.state, + order: canonical.order, + }); +} + +export function sessionLifecycleOperationsEqual( + left: SessionLifecycleOperation, + right: SessionLifecycleOperation +): boolean { + return encodeSessionLifecycleOperation(left) === encodeSessionLifecycleOperation(right); +} + +export function resolveSessionLifecycleRevision( + values: Iterable, + options: { + /** Unknown and deleted targets remain absent and cannot be resurrected. */ + existingSessionIds?: ReadonlySet; + } = {} +): SessionLifecycleRevision { + const byOperationId = new Map(); + for (const value of values) { + const operation = canonicalizeSessionLifecycleOperation(parseSessionLifecycleOperation(value)); + const existing = byOperationId.get(operation.operationId); + if (existing && !sessionLifecycleOperationsEqual(existing, operation)) { + throw new SessionLifecycleOperationConflictError(operation.operationId); + } + byOperationId.set(operation.operationId, operation); + } + + const operations = [...byOperationId.values()].sort(compareSessionLifecycleOrder); + const bySessionId = new Map(); + for (const operation of operations) { + for (const targetId of operation.targetIds) { + if (options.existingSessionIds && !options.existingSessionIds.has(targetId)) continue; + const previous = bySessionId.get(targetId); + if ( + previous && + compareSessionLifecycleOrder( + { operationId: previous.operationId, order: previous.order }, + operation + ) >= 0 + ) { + continue; + } + bySessionId.set(targetId, { + operationId: operation.operationId, + state: operation.state, + order: operation.order, + }); + } + } + + const operationIds = operations.map((operation) => operation.operationId); + const winners = [...bySessionId.entries()] + .sort(([left], [right]) => compareUtf8(left, right)) + .map(([sessionId, winner]) => [sessionId, winner.operationId, winner.state]); + return { + revisionId: JSON.stringify([operationIds, winners]), + operationIds, + bySessionId, + }; +} + +export function getEffectiveSessionArchivedState( + revision: SessionLifecycleRevision, + sessionId: SessionId, + baseline = false +): boolean { + const winner = revision.bySessionId.get(sessionId); + return winner ? winner.state === 'archived' : baseline; +} diff --git a/packages/shared/tests/session-lifecycle-repository.test.ts b/packages/shared/tests/session-lifecycle-repository.test.ts new file mode 100644 index 000000000..8c69931db --- /dev/null +++ b/packages/shared/tests/session-lifecycle-repository.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, it } from 'vitest'; +import { LoroRepo, type JsonObject } from 'loro-repo'; +import type { SessionId } from '../src/ids'; +import { + SessionLifecycleAdmissionUncertainError, + SessionLifecycleAdmissionRejectedError, + createLoroMetaSessionLifecyclePublisher, + installSessionLifecycleRepoProjection, + SessionLifecycleRepository, + type SessionLifecycleAdmission, + type SessionLifecycleAdmissionStore, + type SessionLifecycleOperationDraft, + type SessionLifecycleOperationPublisher, +} from '../src/session-lifecycle-repository'; +import type { SessionLifecycleOperation } from '../src/session-lifecycle'; + +const id = (value: string): SessionId => value as SessionId; + +class MemoryStore implements SessionLifecycleAdmissionStore { + readonly admissions = new Map(); + highWater = 0n; + throwBefore = false; + throwAfter = false; + throwRead = false; + throwMarkPublished = false; + onSeed: (() => void) | null = null; + + async list(): Promise { + return [...this.admissions.values()]; + } + + async get(operationId: string): Promise { + if (this.throwRead) throw new Error('read unavailable'); + return this.admissions.get(operationId); + } + + async admit( + draft: SessionLifecycleOperationDraft, + actorId: string, + observedCounter: string + ): Promise { + if (this.throwBefore) throw new SessionLifecycleAdmissionRejectedError('before write'); + const existing = this.admissions.get(draft.operationId); + if (existing) { + const targets = [...draft.targetIds].sort(); + if ( + existing.operation.subjectId !== draft.subjectId || + existing.operation.state !== draft.state || + JSON.stringify(existing.operation.targetIds) !== JSON.stringify(targets) + ) { + throw new Error(`conflicting operation ${draft.operationId}`); + } + return existing; + } + this.highWater = + [this.highWater, BigInt(observedCounter)].reduce((a, b) => (a > b ? a : b)) + 1n; + const admission: SessionLifecycleAdmission = { + operation: { + version: 1, + ...draft, + targetIds: [...draft.targetIds].sort(), + order: { counter: this.highWater.toString(10), actorId }, + }, + published: false, + }; + this.admissions.set(draft.operationId, admission); + if (this.throwAfter) throw new Error('after write'); + return admission; + } + + async observeCounter(counter: string): Promise { + const next = BigInt(counter); + if (next > this.highWater) this.highWater = next; + } + + async seed(operation: SessionLifecycleOperation): Promise { + this.onSeed?.(); + const existing = this.admissions.get(operation.operationId); + if (existing) return existing; + const admission = { operation, published: false }; + this.admissions.set(operation.operationId, admission); + return admission; + } + + async markPublished(operationId: string): Promise { + if (this.throwMarkPublished) throw new Error('mark failed'); + const admission = this.admissions.get(operationId); + if (admission) this.admissions.set(operationId, { ...admission, published: true }); + } +} + +class MemoryPublisher implements SessionLifecycleOperationPublisher { + readonly operations = new Map(); + readonly listeners = new Set<(operation: unknown) => void>(); + failPublish = false; + + async list(): Promise { + return [...this.operations.values()]; + } + + async publish(operation: SessionLifecycleOperation): Promise { + if (this.failPublish) throw new Error('publish failed'); + this.operations.set(operation.operationId, operation); + for (const listener of this.listeners) listener(operation); + } + + subscribe(listener: (operation: unknown) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + emit(operation: unknown): void { + for (const listener of this.listeners) listener(operation); + } +} + +const draft = (operationId: string, state: 'archived' | 'active' = 'archived') => ({ + operationId, + subjectId: id('root'), + targetIds: [id('root'), id('child')], + state, +}); + +describe('SessionLifecycleRepository', () => { + it('admits durably before publishing and exposes one complete revision', async () => { + const store = new MemoryStore(); + const publisher = new MemoryPublisher(); + const repository = new SessionLifecycleRepository({ actorId: 'actor-a', store, publisher }); + await repository.initialize(); + const events: string[][] = []; + repository.subscribe(({ revision }) => events.push([...revision.bySessionId.keys()])); + + const receipt = await repository.commit(draft('archive')); + + expect(receipt.durability).toBe('accepted'); + expect(receipt.publication).toBe('published'); + expect(events).toEqual([['child', 'root']]); + expect((await store.get('archive'))?.published).toBe(true); + }); + + it('publishes nothing when target validation fails before admission', async () => { + const store = new MemoryStore(); + const publisher = new MemoryPublisher(); + const repository = new SessionLifecycleRepository({ actorId: 'actor-a', store, publisher }); + await repository.initialize(); + + await expect( + repository.commit({ ...draft('invalid'), targetIds: [id('root'), id('root')] }) + ).rejects.toThrow('targetIds must not contain duplicates'); + expect(store.admissions.size).toBe(0); + expect(publisher.operations.size).toBe(0); + }); + + it('returns a durable pending receipt and replays the same operation after restart', async () => { + const store = new MemoryStore(); + const publisher = new MemoryPublisher(); + publisher.failPublish = true; + const first = new SessionLifecycleRepository({ actorId: 'actor-a', store, publisher }); + await first.initialize(); + const receipt = await first.commit(draft('archive')); + expect(receipt.publication).toBe('pending'); + await first.dispose(); + + publisher.failPublish = false; + const recovered = new SessionLifecycleRepository({ actorId: 'actor-a', store, publisher }); + await recovered.initialize(); + expect(publisher.operations.get('archive')).toEqual(receipt.operation); + expect((await store.get('archive'))?.published).toBe(true); + }); + + it('replays idempotently when publication succeeded before completion marking failed', async () => { + const store = new MemoryStore(); + const publisher = new MemoryPublisher(); + store.throwMarkPublished = true; + const first = new SessionLifecycleRepository({ actorId: 'actor-a', store, publisher }); + await first.initialize(); + const receipt = await first.commit(draft('archive')); + expect(receipt.publication).toBe('pending'); + expect(publisher.operations.get('archive')).toEqual(receipt.operation); + await first.dispose(); + + store.throwMarkPublished = false; + const recovered = new SessionLifecycleRepository({ actorId: 'actor-a', store, publisher }); + await recovered.initialize(); + expect(publisher.operations.size).toBe(1); + expect((await store.get('archive'))?.published).toBe(true); + }); + + it('recovers an admission whose durable write succeeded before the call rejected', async () => { + const store = new MemoryStore(); + store.throwAfter = true; + const repository = new SessionLifecycleRepository({ + actorId: 'actor-a', + store, + publisher: new MemoryPublisher(), + }); + await repository.initialize(); + const receipt = await repository.commit(draft('archive')); + expect(receipt.operation.order.counter).toBe('1'); + }); + + it('reports a definite rejection when the store confirms no admission', async () => { + const store = new MemoryStore(); + store.throwBefore = true; + const repository = new SessionLifecycleRepository({ + actorId: 'actor-a', + store, + publisher: new MemoryPublisher(), + }); + await repository.initialize(); + await expect(repository.commit(draft('archive'))).rejects.toBeInstanceOf( + SessionLifecycleAdmissionRejectedError + ); + }); + + it('reports an uncertain stable id when admission and recovery reads both fail', async () => { + const store = new MemoryStore(); + store.throwBefore = true; + store.throwRead = true; + const repository = new SessionLifecycleRepository({ + actorId: 'actor-a', + store, + publisher: new MemoryPublisher(), + }); + await repository.initialize(); + await expect( + repository.commit(draft('archive')) + ).rejects.toMatchObject({ + operationId: 'archive', + }); + }); + + it('keeps counters above observed and admitted operations across concurrent calls', async () => { + const store = new MemoryStore(); + const publisher = new MemoryPublisher(); + publisher.operations.set('remote', { + version: 1, + operationId: 'remote', + subjectId: id('root'), + targetIds: [id('root')], + state: 'archived', + order: { counter: '40', actorId: 'remote' }, + }); + const repository = new SessionLifecycleRepository({ actorId: 'actor-a', store, publisher }); + await repository.initialize(); + const [first, second] = await Promise.all([ + repository.commit(draft('first')), + repository.commit(draft('second', 'active')), + ]); + expect(first.operation.order.counter).toBe('41'); + expect(second.operation.order.counter).toBe('42'); + }); + + it('does not lose remote operations delivered while migration baselines are being seeded', async () => { + const store = new MemoryStore(); + const publisher = new MemoryPublisher(); + const remote: SessionLifecycleOperation = { + version: 1, + operationId: 'remote-during-seed', + subjectId: id('root'), + targetIds: [id('root')], + state: 'archived', + order: { counter: '9', actorId: 'remote' }, + }; + store.onSeed = () => publisher.emit(remote); + const repository = new SessionLifecycleRepository({ actorId: 'actor-a', store, publisher }); + + await repository.initialize({ + baselines: [ + { + version: 1, + operationId: 'baseline:v1:child', + subjectId: id('child'), + targetIds: [id('child')], + state: 'archived', + order: { counter: '0', actorId: 'baseline:v1' }, + }, + ], + }); + + expect(repository.getOperation(remote.operationId)).toEqual(remote); + expect(repository.getRevision().bySessionId.get(id('root'))?.operationId).toBe( + remote.operationId + ); + expect((await repository.commit(draft('after-init'))).operation.order.counter).toBe('10'); + }); + + it('reuses an admitted operation identity without advancing its order', async () => { + const store = new MemoryStore(); + const repository = new SessionLifecycleRepository({ + actorId: 'actor-a', + store, + publisher: new MemoryPublisher(), + }); + await repository.initialize(); + + const first = await repository.commit(draft('stable')); + const retried = await repository.commit(draft('stable')); + + expect(retried.operation).toEqual(first.operation); + expect(store.highWater).toBe(1n); + await expect(repository.commit(draft('stable', 'active'))).rejects.toThrow( + 'Conflicting payloads use lifecycle operation id stable' + ); + }); + + it('fails closed after an unsupported replicated schema version', async () => { + const publisher = new MemoryPublisher(); + const repository = new SessionLifecycleRepository({ + actorId: 'actor-a', + store: new MemoryStore(), + publisher, + }); + await repository.initialize(); + + publisher.emit({ version: 2, operationId: 'future' }); + + await expect(repository.flushPending()).rejects.toThrow( + 'rejected a replicated operation: Unsupported lifecycle operation version 2' + ); + }); + + it('installs a replicated multi-target operation before notifying projected readers', async () => { + const source = await LoroRepo.create({ metaDebounceCommitMs: 0 }); + const replica = await LoroRepo.create({ metaDebounceCommitMs: 0 }); + const root = id('root'); + const child = id('child'); + const roomId = (sessionId: SessionId) => `session-${sessionId}`; + const fromRoomId = (docId: string) => + docId.startsWith('session-') ? (docId.slice('session-'.length) as SessionId) : null; + for (const sessionId of [root, child]) { + await replica.upsertDocMeta(roomId(sessionId), { + id: sessionId, + isArchived: false, + status: { type: 'idle' }, + unrelated: `kept-${sessionId}`, + }); + } + const repository = new SessionLifecycleRepository({ + actorId: 'replica', + store: new MemoryStore(), + publisher: createLoroMetaSessionLifecyclePublisher(replica), + }); + await repository.initialize(); + installSessionLifecycleRepoProjection({ + repo: replica, + repository, + getSessionId: fromRoomId, + getSessionDocId: roomId, + rejectLegacyWrites: true, + }); + + const firstProjectedRead = new Promise((resolve, reject) => { + const handle = replica.watch( + () => { + void (async () => { + const snapshots = await replica.getDocMetaMany([roomId(root), roomId(child)]); + handle.unsubscribe(); + resolve([ + snapshots.get(roomId(root))?.meta ?? {}, + snapshots.get(roomId(child))?.meta ?? {}, + ]); + })().catch(reject); + }, + { kinds: ['doc-metadata'], metadataFields: ['isArchived'] } + ); + }); + await createLoroMetaSessionLifecyclePublisher(source).publish({ + version: 1, + operationId: 'replicated-archive', + subjectId: root, + targetIds: [root, child], + state: 'archived', + order: { counter: '1', actorId: 'source' }, + }); + replica.getMeta().importJson(source.getMeta().exportJson()); + await (replica as unknown as { syncRunner: { metaHydrationQueue: Promise } }).syncRunner + .metaHydrationQueue; + + const [rootMeta, childMeta] = await firstProjectedRead; + expect(rootMeta).toMatchObject({ + isArchived: true, + status: { type: 'idle' }, + unrelated: 'kept-root', + }); + expect(childMeta).toMatchObject({ + isArchived: true, + status: { type: 'idle' }, + unrelated: 'kept-child', + }); + await expect(replica.upsertDocMeta(roomId(root), { isArchived: false })).rejects.toThrow( + 'Direct isArchived writes are disabled' + ); + + await repository.dispose(); + await Promise.all([source.destroy(), replica.destroy()]); + }); +}); diff --git a/packages/shared/tests/session-lifecycle.test.ts b/packages/shared/tests/session-lifecycle.test.ts new file mode 100644 index 000000000..96ae33860 --- /dev/null +++ b/packages/shared/tests/session-lifecycle.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import type { SessionId } from '../src/ids'; +import { + compareSessionLifecycleOrder, + encodeSessionLifecycleOperation, + getEffectiveSessionArchivedState, + parseSessionLifecycleOperation, + resolveSessionLifecycleRevision, + SessionLifecycleOperationConflictError, + type SessionLifecycleOperation, +} from '../src/session-lifecycle'; + +const id = (value: string): SessionId => value as SessionId; +const operation = ( + operationId: string, + counter: string, + actorId: string, + targetIds: string[], + state: 'archived' | 'active' = 'archived', + subjectId = targetIds[0] ?? '' +): SessionLifecycleOperation => ({ + version: 1, + operationId, + subjectId: id(subjectId), + targetIds: targetIds.map(id), + state, + order: { counter, actorId }, +}); + +describe('session lifecycle operation protocol', () => { + it('parses a complete record and rejects unknown versions and non-canonical counters', () => { + expect(parseSessionLifecycleOperation(operation('op-1', '12', 'actor-a', ['root']))).toEqual( + operation('op-1', '12', 'actor-a', ['root']) + ); + expect(() => parseSessionLifecycleOperation({ ...operation('op-1', '1', 'a', ['root']), version: 2 })).toThrow( + /Unsupported/ + ); + expect(() => parseSessionLifecycleOperation(operation('op-1', '01', 'a', ['root']))).toThrow( + /canonical/ + ); + expect(() => parseSessionLifecycleOperation(operation('op-1', '1', 'a', ['child'], 'active', 'root'))).toThrow( + /include subjectId/ + ); + }); + + it('uses numeric counters followed by stable actor and operation byte order', () => { + expect(compareSessionLifecycleOrder(operation('z', '9', 'z', ['root']), operation('a', '10', 'a', ['root']))).toBeLessThan(0); + expect(compareSessionLifecycleOrder(operation('z', '10', 'a', ['root']), operation('a', '10', 'b', ['root']))).toBeLessThan(0); + expect(compareSessionLifecycleOrder(operation('a', '10', 'b', ['root']), operation('z', '10', 'b', ['root']))).toBeLessThan(0); + }); + + it('canonicalizes target order without changing frozen membership', () => { + expect(JSON.parse(encodeSessionLifecycleOperation(operation('op', '1', 'a', ['root', 'b', 'a']))).targetIds).toEqual([ + 'a', + 'b', + 'root', + ]); + }); +}); + +describe('session lifecycle resolver', () => { + it('makes a later root operation win for every frozen target regardless of delivery order', () => { + const archive = operation('archive', '1', 'a', ['root', 'child']); + const restore = operation('restore', '2', 'a', ['root', 'child'], 'active'); + const forward = resolveSessionLifecycleRevision([archive, restore]); + const reverse = resolveSessionLifecycleRevision([restore, archive, restore]); + expect(forward.revisionId).toBe(reverse.revisionId); + expect(getEffectiveSessionArchivedState(forward, id('root'))).toBe(false); + expect(getEffectiveSessionArchivedState(forward, id('child'))).toBe(false); + }); + + it('uses stable ties for concurrent root operations under duplicate reverse delivery', () => { + const actorA = operation('operation-z', '5', 'actor-a', ['root', 'child']); + const actorB = operation('operation-a', '5', 'actor-b', ['root', 'child'], 'active'); + const forward = resolveSessionLifecycleRevision([actorA, actorB, actorA]); + const reverse = resolveSessionLifecycleRevision([actorB, actorA, actorB]); + + expect(reverse.revisionId).toBe(forward.revisionId); + expect(forward.bySessionId.get(id('root'))?.operationId).toBe('operation-a'); + expect(forward.bySessionId.get(id('child'))?.operationId).toBe('operation-a'); + }); + + it('allows a newer singleton Tab operation to override only that Tab', () => { + const revision = resolveSessionLifecycleRevision([ + operation('root-archive', '5', 'a', ['root', 'tab']), + operation('tab-restore', '6', 'a', ['tab'], 'active', 'tab'), + ]); + expect(getEffectiveSessionArchivedState(revision, id('root'))).toBe(true); + expect(getEffectiveSessionArchivedState(revision, id('tab'))).toBe(false); + }); + + it('retains the last covering operation when later frozen sets differ', () => { + const revision = resolveSessionLifecycleRevision([ + operation('first', '1', 'a', ['root', 'old-child']), + operation('second', '2', 'a', ['root', 'new-child'], 'active'), + ]); + expect(getEffectiveSessionArchivedState(revision, id('root'))).toBe(false); + expect(getEffectiveSessionArchivedState(revision, id('new-child'))).toBe(false); + expect(getEffectiveSessionArchivedState(revision, id('old-child'))).toBe(true); + }); + + it('filters unknown and deleted targets without reviving them', () => { + const revision = resolveSessionLifecycleRevision( + [operation('archive', '1', 'a', ['root', 'missing'])], + { existingSessionIds: new Set([id('root')]) } + ); + expect(revision.bySessionId.has(id('root'))).toBe(true); + expect(revision.bySessionId.has(id('missing'))).toBe(false); + }); + + it('rejects one operation id with different immutable payloads', () => { + expect(() => + resolveSessionLifecycleRevision([ + operation('same', '1', 'a', ['root']), + operation('same', '1', 'a', ['root'], 'active'), + ]) + ).toThrow(SessionLifecycleOperationConflictError); + }); +}); diff --git a/specs/session-relations.md b/specs/session-relations.md index 915feaa84..17fa23477 100644 --- a/specs/session-relations.md +++ b/specs/session-relations.md @@ -1,7 +1,9 @@ # Session relations and operation targets Status: draft -Translation: pending +Translation: current + +[中文](session-relations.zh.md) A root Session may contain child Tabs and may also open independent Sessions. These relationships carry different guarantees. For example: @@ -35,17 +37,74 @@ behavior for malformed or legacy nested children. ## Operation contract -| Operation | Targets | Metadata readiness | -| ----------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| Archive a Session | The selected Session and direct children whose `parentSessionId` equals its id. | Target discovery must use complete metadata; see the implementation gap below. | -| Restore a Session | The selected Session and the same direct children. | Target discovery must use complete metadata. | -| Permanently delete an archived root | The selected Session and the same direct children. | Reject before mutation unless the metadata set used for discovery is complete. | -| Delete exact Session ids | Exactly the ids supplied by the caller. | Must not wait for global metadata hydration or discover additional Sessions. | +| Operation | Targets | Metadata readiness | +| ----------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| Archive a Session | The selected Session and direct children whose `parentSessionId` equals its id. | Target discovery reads the repository metadata snapshot observed by the action. | +| Restore a Session | The selected Session and the same direct children. | Target discovery must use complete metadata. | +| Permanently delete an archived root | The selected Session and the same direct children. | Reject before mutation unless the metadata set used for discovery is complete. | +| Delete exact Session ids | Exactly the ids supplied by the caller. | Must not wait for global metadata hydration or discover additional Sessions. | Every side effect follows the same target set as the state or document operation. Terminal closure, machine commands and queues, launch-config removal, and worktree cleanup must not affect a Session excluded from the operation targets. +### Archive and restore commit + +One archive or restore operation freezes its selected Session and discovered direct +children before submission. The repository publishes that operation's effective +lifecycle changes as one revision. An observer may see the preceding revision or the +following revision, but never a subset caused by applying that operation incrementally. +Replicas may receive a revision at different times; this is not a promise of simultaneous +visibility across disconnected clients. + +A failure before acceptance leaves no change from that operation. Once an operation +has been accepted, a persistence or acknowledgement failure must retain its identity +and distinguish an unconfirmed outcome from rejection. Neither case authorizes writing +old `isArchived` or execution `status` values back over current metadata. Success means +the operation is locally durable; remote synchronization and resource cleanup have +separate completion boundaries. Cross-restart recovery requires a persisted operation, +not an in-memory error or retry flag. + +Recovery may deliver the same operation or revision more than once. Readers and resource +owners must handle this idempotently; replay neither creates a new logical operation nor +raises its precedence. Cross-crash notification delivery is not exactly once. + +Concurrent root lifecycle operations with identical frozen target sets choose one +winner for that set. When sets differ, shared targets use the same operation ordering; +a target absent from the newer operation retains its last applicable result. A later +independent Tab operation may affect only that Tab, and a later root operation may +supersede that Tab operation when the Tab is included. Therefore +`root active / child archived` can be intentional; a failed root operation may not +produce that combination by changing only some of its targets. Lifecycle writes do not +own execution status: the runtime publishes its actual state, and restore never revives +a captured `running` or `requestPermission` value. + +Terminal closure begins after the lifecycle commit. Cleanup observes current effective +state and coordinates with start/resume across asynchronous resource work; an old +archive task must not destroy a new runtime generation after restore. Cleanup failure is +reported and retried by its resource owner without reversing the lifecycle operation. +These resource effects may finish at different times; atomic lifecycle publication does +not promise atomic termination of multiple processes. + +An operation remains bound to its captured workspace. A workspace switch before +submission aborts without mutation; after acceptance the originating runtime owns +confirmation and recovery. A rendered root is presentation evidence, not an authoritative +source for membership, prior state, or a lifecycle commit. + +### Compatibility + +Every participating writer and lifecycle reader must share the operation and projection +contract before the new representation is enabled. A Machine capability describes that +daemon; it cannot establish compatibility of other independently authoring renderers. +Legacy rows need an explicit migration baseline, and later legacy writes need a tested +admission or compatibility policy. This policy and the activation mechanism must be +proven before replacing the production path, not deferred until after its removal. +Best-effort dual writes to independent archive flags +do not establish this contract. Unsupported clients and retained offline writers remain +a rollout prerequisite, not evidence that the weaker invariant is acceptable. + +### Exact deletion + Exact deletion exists for compensation and explicit cleanup where the caller already knows the complete set, including a partially created child, an empty child Tab, or a side Session whose runtime was terminated. Requiring a complete metadata scan in @@ -76,11 +135,25 @@ This Spec does not define worker supervision, status or result aggregation, unre permission routing, worker panels, settle, or handoff behavior. Those product choices remain separate in [#529](https://github.com/LodyAI/Lody/issues/529). -Archive and restore currently discover direct children from a client metadata cache -that can be incomplete while Session Detail is already interactive. They can therefore -miss a child during cold-start hydration, contrary to the target contract above. -[#574](https://github.com/LodyAI/Lody/issues/574) tracks the required readiness or -complete-query fix. +Archive and restore now read one repository metadata snapshot before submission; a +query or workspace-ownership failure leaves no operation. In the coordinated local OSS +topology, both actions durably admit one immutable operation and project its complete +target set through the repository seam. Browser admission uses workspace-scoped +IndexedDB, CLI admission uses a dedicated workspace SQLite database, and both replay +unpublished records without changing their identity or order. Existing archived flags +become deterministic counter-zero baselines. Direct legacy archive writes are rejected +after activation, while initial active metadata remains valid for a newly created +Session. + +The local implementation does not include children created after its discovery +snapshot, recursive containment, operation compaction, or atomic permanent deletion. +Cloud and dual product topologies retain the legacy independent-write path because the +public repository cannot fence independently deployed or offline renderers. They must +not enable the new representation until the mixed-client compatibility requirement +above has external evidence; a Machine capability is insufficient. Consequently this +Spec remains draft and #574 remains open for product topology. The +[decision record](../.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md) +owns the storage layout, verification evidence, and remaining rollout gate. ## Evidence @@ -95,7 +168,16 @@ CLI direct-child selection and the nested-child rejection are in [`use-session-actions.test.ts`](../packages/components/tests/use-session-actions.test.ts) and [`session-navigation.test.ts`](../packages/components/tests/session-navigation.test.ts). +The operation model and repository projection are covered by +[`session-lifecycle.test.ts`](../packages/shared/tests/session-lifecycle.test.ts) and +[`session-lifecycle-repository.test.ts`](../packages/shared/tests/session-lifecycle-repository.test.ts). +Browser and CLI durability are covered by their adjacent +[`session-lifecycle-persistence.spec.ts`](../packages/components/tests/e2e/session-lifecycle-persistence.spec.ts) +and +[`session-lifecycle-persistence.test.ts`](../apps/cli/src/lib/loro/session-lifecycle-persistence.test.ts) +suites. -This draft records the relation and operation guarantees implemented by -[#569](https://github.com/LodyAI/Lody/pull/569). Human approval of the complete -contract remains pending. +The containment target rules were implemented by +[#569](https://github.com/LodyAI/Lody/pull/569). The archive acceptance criteria remain +tracked by [#574](https://github.com/LodyAI/Lody/issues/574); discovery alone does not +establish mixed-product-client compatibility. Human approval of this draft remains pending. diff --git a/specs/session-relations.zh.md b/specs/session-relations.zh.md new file mode 100644 index 000000000..06427c763 --- /dev/null +++ b/specs/session-relations.zh.md @@ -0,0 +1,87 @@ +# Session 关系与操作目标 + +Status: draft +Translation: current + +[English](session-relations.md) + +根 Session 可以包含子 Tab,也可以打开独立的 Session。这些关系承载不同的保证。例如: + +```text +Session A +|- Tab T parentSessionId=A +|- Session B openedBySessionId=A +`- T opens Session C openedBySessionId=T, openedByRootSessionId=A +``` + +Tab T 是 A 的组成部分。即使 Session B 和 C 是由 A 或 T 发起创建的,它们仍是一级 Session。状态操作必须根据与操作相匹配的关系选择目标;这些字段不构成一棵生命周期树。 + +## 关系契约 + +| 关系 | 含义 | 操作后果 | +| ----------------------- | ----------------------------------------------------- | ---------------------------------------------------------- | +| `parentSessionId` | 直接包含关系。子 Tab 与根 Session 共享工作区。 | 根 Session 的归档、恢复和已归档根的删除包含直接子级。 | +| `openedBySessionId` | 创建该 Session 的精确 Session 或 Tab。 | 为溯源和展示保留;绝不能据此推断归档、恢复或删除的所有权。 | +| `openedByRootSessionId` | 精确发起者是子 Tab 时的根路由。 | 与精确发起者一起用于导航;绝不能用于选择状态操作目标。 | +| 资源元数据 | Session 所拥有的机器、项目、分支、工作区或 worktree。 | 只有当该 Session 是操作目标时才清理资源。 | + +`openedByRootSessionId` 是对 `openedBySessionId` 的补充而非替代:前者标识可路由的根,后者保留精确的因果来源。 + +目前只支持直接包含。支持的创建路径会拒绝父级本身已有 `parentSessionId` 的子级;状态操作不得为格式错误或遗留的嵌套子级递归创造行为。 + +## 操作契约 + +| 操作 | 目标 | 元数据就绪条件 | +| ------------------- | ------------------------------------------------------------- | ------------------------------------------------ | +| 归档 Session | 选中的 Session,以及 `parentSessionId` 等于其 id 的直接子级。 | 目标发现读取操作所观察到的仓库元数据快照。 | +| 恢复 Session | 选中的 Session,以及相同的直接子级。 | 目标发现必须使用完整元数据。 | +| 永久删除已归档根 | 选中的 Session,以及相同的直接子级。 | 除非用于发现的元数据集合完整,否则在变更前拒绝。 | +| 删除精确 Session id | 调用者提供的、恰好那些 id。 | 不得等待全局元数据加载,也不得发现额外 Session。 | + +每个副作用都遵循与状态或文档操作相同的目标集合。终端关闭、机器命令和队列、启动配置移除以及 worktree 清理不得影响被排除在操作目标之外的 Session。 + +### 归档与恢复提交 + +每个归档或恢复操作会在提交前冻结选中的 Session 及发现出的直接子级。仓库将该操作的有效生命周期变更作为一个修订发布。观察者可能看到前一个或后一个修订,但绝不会看到因逐步应用该操作而产生的子集。副本可能在不同时间收到一个修订;这不承诺断开连接的客户端同时可见。 + +接受前失败不会留下该操作的变更。操作一旦被接受,持久化或确认失败必须保留其身份,并区分未确认结果与拒绝。两种情况都不允许把旧的 `isArchived` 或执行 `status` 值写回当前元数据。成功意味着操作已在本地持久化;远程同步和资源清理有各自独立的完成边界。跨重启恢复需要持久化操作,而不是内存中的错误或重试标志。 + +恢复可能多次交付同一个操作或修订。读取者与资源所有者必须幂等处理;重放既不创建新的逻辑操作,也不提升其优先级。跨崩溃通知不保证恰好一次交付。 + +冻结目标集相同的并发根生命周期操作,对整个集合选择同一胜者。集合不同时,共有目标使用相同的操作排序;不在较新操作中的目标保留最后一个适用的结果。后续独立的 Tab 操作仍可以只影响该 Tab,而较新的根操作在包含该 Tab 时也可以覆盖其独立操作。因此 `root active / child archived` 可能是有意状态;失败的根操作不得通过只变更部分目标产生这种组合。生命周期写入不拥有执行状态:运行时发布实际状态,恢复绝不会复活捕获的 `running` 或 `requestPermission` 值。 + +终端关闭在生命周期提交后开始。清理观察当前有效状态,并在异步资源处理期间与 start/resume 协调;旧归档任务不得销毁恢复后产生的新 runtime 代次。清理失败由其资源所有者报告并重试,不会反转生命周期操作。这些资源副作用可以在不同时间完成;生命周期的原子发布不承诺多个进程的原子终止。 + +操作始终绑定到捕获的工作区。提交前切换工作区会在不产生变更的情况下中止;接受后,发起操作的运行时拥有确认和恢复。渲染出的根只是展示证据,不是成员关系、先前状态或生命周期提交的权威来源。 + +### 兼容性 + +在启用新表示之前,每个参与的写入者和生命周期读取者都必须共享该操作与投影契约。Machine capability 描述的是该 daemon;它不能建立其他独立写入的 renderer 的兼容性。遗留行需要明确的迁移基线,之后的遗留写入需要经过测试的准入或兼容策略。这一策略与启用机制必须在替换生产路径前得到证明,不能延后到旧路径移除之后。对独立归档标志进行尽力双写并不能建立该契约。不受支持的客户端和仍处于离线状态的写入者仍是发布前提,而不是可以接受较弱不变量的证据。 + +### 精确删除 + +精确删除用于补偿和显式清理,此时调用者已经知道完整集合,包括部分创建的子级、空的子 Tab 或运行时已终止的旁 Session。在这些路径要求完整元数据扫描会阻止加载期间的清理,并可能留下部分状态。 + +对于开头的场景,归档或恢复 A 会影响 A 和 T,但不会影响 B 或 C。删除已归档的 A 根会删除 A 和 T,而 B、C 存活。精确删除 T 只删除 T。 + +## 删除后的溯源 + +删除发起者不得从存活的 Session 中擦除 `openedBySessionId` 或 `openedByRootSessionId`。这些 id 保存了无法事后重建的因果事实。 + +溯源与导航是分开的。单独一个 id 不是可导航目标。元数据加载完成后,只有精确发起者和其路由根都存在时,反向导航才可操作。如果任一缺失,客户端可以将该关系展示为已删除历史,但不得路由到缺失的 Session。本契约不要求 tombstone 或已删除标题。 + +已归档和活跃列表可以使用 opened-by 溯源来分组或缩进 Session。展示不得扩大归档、恢复或删除的目标集合。 + +## 范围与实现缺口 + +本 Spec 不定义 worker 监管、状态或结果聚合、未读或权限路由、worker 面板、settle 或 handoff 行为。这些产品选择仍属于 [#529](https://github.com/LodyAI/Lody/issues/529) 的范围。 + +归档与恢复现在都会在提交前读取同一个仓库元数据快照;查询或工作区所有权失败不会留下操作。在协调升级的本地 OSS 拓扑中,两种动作都会持久准入一个不可变操作,并通过仓库接缝投影其完整目标集合。浏览器准入使用工作区级 IndexedDB,CLI 准入使用专用工作区 SQLite 数据库;两者都会重放尚未发布的记录,而不改变其身份或排序。已有 archived flag 会成为确定性的 counter-zero baseline。本地启用后会拒绝直接写入遗留归档值;新建 Session 的初始 active 元数据仍然合法。 + +本地实现不涵盖发现快照之后创建的子级、递归包含、operation 历史压缩或永久删除的原子性。cloud 与 dual 产品拓扑仍保留遗留的独立写入路径,因为公共仓库无法隔离独立发布或离线的 renderer。在获得上文要求的外部混合客户端兼容证据前,这些拓扑不得启用新表示;Machine capability 不足以作为证据。因此本 Spec 仍为 draft,#574 对产品拓扑仍保持开放。[决策记录](../.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md)负责维护存储布局、验证证据和剩余发布门槛。 + +## 证据 + +报告的行为和支持的场景见 [#531](https://github.com/LodyAI/Lody/issues/531)。客户端目标选择和精确清理位于 [`use-session-actions.ts`](../packages/components/src/hooks/use-session-actions.ts),反向导航解析位于 [`session-navigation.ts`](../packages/components/src/lib/session-navigation.ts)。CLI 直接子级选择和嵌套子级拒绝位于 [`session.ts`](../apps/cli/src/commands/session.ts)。行为覆盖位于 [`use-session-actions.test.ts`](../packages/components/tests/use-session-actions.test.ts) 和 [`session-navigation.test.ts`](../packages/components/tests/session-navigation.test.ts)。操作模型与仓库投影由 [`session-lifecycle.test.ts`](../packages/shared/tests/session-lifecycle.test.ts) 和 [`session-lifecycle-repository.test.ts`](../packages/shared/tests/session-lifecycle-repository.test.ts) 覆盖。浏览器与 CLI 持久性分别由相邻的 [`session-lifecycle-persistence.spec.ts`](../packages/components/tests/e2e/session-lifecycle-persistence.spec.ts) 和 [`session-lifecycle-persistence.test.ts`](../apps/cli/src/lib/loro/session-lifecycle-persistence.test.ts) 覆盖。 + +包含关系目标规则由 [#569](https://github.com/LodyAI/Lody/pull/569) 实现。归档接受标准仍由 [#574](https://github.com/LodyAI/Lody/issues/574) 跟踪;本地证据不能建立产品混合客户端兼容性。本 draft 仍待人工批准。