From 08fb2f280c09ecb179cef7147d1c5b4c71d8b4ac Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:12:30 +0800 Subject: [PATCH 1/5] fix(components): query complete metadata before archive Discover archive targets from the repository metadata index before any state writes, while preserving rendered-root fallback and direct-child-only lifecycle ownership. Closes #574 Model: gpt-5 --- ...session-archive-complete-metadata-query.md | 48 ++++++++++ ...sion-archive-complete-metadata-query.zh.md | 38 ++++++++ .../src/hooks/use-session-actions.ts | 39 +++++--- .../tests/use-session-actions.test.ts | 89 ++++++++++++++++++- specs/session-relations.md | 12 +-- 5 files changed, 204 insertions(+), 22 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-13-session-archive-complete-metadata-query.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-13-session-archive-complete-metadata-query.zh.md 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..6f9439f88 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-13-session-archive-complete-metadata-query.md @@ -0,0 +1,48 @@ +# Discover archive targets from complete repository metadata + +Status: implemented +Translation: current + +Contract: [Session relations and operation targets](../../../../specs/session-relations.md) + +[中文](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 target discovery now reads the repository +metadata index for each action and performs no writes if that query fails. This avoids +both a partial-cache archive and an unbounded wait for global projection readiness, at +the cost of one metadata-index scan per archive action. + +## 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 index lags that already-visible +document, but descendant discovery never falls back to the UI cache. + +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. + +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 starts with a UI cache containing only the root while the +repository index contains its direct child and independently opened Sessions. It +verifies that archive updates the root and child, leaves both independent Sessions +active, and closes only the two lifecycle-owned terminals. A failure case verifies that +an index-query error rejects before any archive metadata write. A deferred-query case +switches workspaces before discovery completes and verifies that the old runtime also +receives no write. + +This implements [#574](https://github.com/LodyAI/Lody/issues/574) and complements the +containment decision recorded 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..8afe81f79 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-13-session-archive-complete-metadata-query.zh.md @@ -0,0 +1,38 @@ +# 从完整仓库元数据中发现归档目标 + +Status: implemented +Translation: current + +Contract: [Session 关系与操作目标](../../../../specs/session-relations.md) + +[English](2026-09-13-session-archive-complete-metadata-query.md) + +## 摘要 + +根 Session 已可交互时,客户端元数据投影可能还没有包含它的直接子 Tab,导致过早归档。 +现在每次归档都会读取仓库元数据索引来发现目标;如果查询失败,则不会执行任何写入。 +这一方案既避免基于不完整缓存归档,也避免无限等待全局投影就绪,代价是每次归档多执行一次元数据索引扫描。 + +## 决策 + +归档操作会在写入任何状态之前取得工作区元数据索引。它从 room id 补全 Session id,只选择直接的 +`parentSessionId` 子项,并在写入前重新确认捕获的工作区 runtime 仍然处于活动状态。当索引暂时落后于 +已经可见的根 Session 时,仍可回退到已渲染的根元数据;但发现后代时绝不回退到 UI 缓存。 + +我们没有选择等待 `docMetaCacheReadyAtom`。就绪状态属于异步 UI 投影;实时事件触发的元数据读取可能失败或 +长期不返回。让用户操作等待这个全局信号会引入无期限 pending 状态。仓库索引本就是构建该投影的数据源, +并且能为归档操作提供明确的成功或失败边界。 + +本次改动刻意不改变恢复与归档根永久删除的行为。它也不会把生命周期所有权扩大到 +`openedBySessionId` 或 `openedByRootSessionId`;独立 Session 在开启者被归档后继续存活。 + +## 验证 + +所属 hook 测试从一个只包含根 Session 的 UI 缓存开始,而仓库索引同时包含其直接子项和独立打开的 +Session。测试验证归档会更新根与子项、保留两个独立 Session,并且只关闭两个生命周期归属目标的终端。 +另一个失败用例验证元数据索引查询报错时,会在任何归档元数据写入之前拒绝操作。延迟查询用例会在 +发现完成前切换工作区,并验证旧 runtime 同样不会收到写入。 + +本次改动实现 [#574](https://github.com/LodyAI/Lody/issues/574),并补充 +[让被打开的 Session 不受开启者状态级联影响](2026-09-10-session-containment-lifecycle.zh.md) +所记录的包含关系决策。 diff --git a/packages/components/src/hooks/use-session-actions.ts b/packages/components/src/hooks/use-session-actions.ts index fd05807db..dd0afa6b6 100644 --- a/packages/components/src/hooks/use-session-actions.ts +++ b/packages/components/src/hooks/use-session-actions.ts @@ -32,6 +32,7 @@ import { formatSessionQuotaRejection, isConvexUnauthenticatedError, isLoroRepoDocDeleted, + isSessionDocRoomId, normalizeSessionTurnInputConfig, readMachineFlockRowsFromFlock, sanitizeMessageTextSpans, @@ -57,6 +58,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 +246,20 @@ function getArchiveStateTargets( ]; } +async function listCompleteSessionMetadata(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 assertArchivedLocalProjectCanRestore( runtime: WorkspaceRuntime, sessionMeta: SessionMeta @@ -1166,13 +1183,15 @@ export function useSessionActions(): SessionActions { } 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 sessionMetadata = await listCompleteSessionMetadata(runtime); + if (store.get(activeWorkspaceRuntimeAtom) !== runtime) { + throw new Error('Workspace changed while loading session metadata'); + } + const repoMeta = sessionMetadata.find((session) => session.id === sessionId); + // The complete repository index is preferred, but it can lag a Session + // the UI already renders. The archive write below is an idempotent patch, + // so rendered root metadata is enough to proceed. Descendant discovery + // still comes exclusively from the complete index above. const sessionMeta = repoMeta ?? (store.get(sessionMetaCacheAtom)[sessionRoomId] as SessionMeta | undefined); if (!sessionMeta) { @@ -1183,11 +1202,7 @@ export function useSessionActions(): SessionActions { machineId: sessionMeta.machineId, }); - const archiveTargets = getArchiveStateTargets( - sessionId, - sessionMeta, - Object.values(store.get(sessionMetaCacheAtom)) - ); + const archiveTargets = getArchiveStateTargets(sessionId, sessionMeta, sessionMetadata); for (const session of archiveTargets) { if (typeof window !== 'undefined') { sendIpc('terminal.closeSession', { sessionId: session.id }); diff --git a/packages/components/tests/use-session-actions.test.ts b/packages/components/tests/use-session-actions.test.ts index aa2be5754..05aafd97f 100644 --- a/packages/components/tests/use-session-actions.test.ts +++ b/packages/components/tests/use-session-actions.test.ts @@ -257,6 +257,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 +316,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 +1126,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() }, @@ -1156,7 +1163,11 @@ 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 }, @@ -1175,12 +1186,15 @@ describe('useSessionActions', () => { ); }); - it('archives child tabs without archiving independently opened session workspaces', async () => { + it('discovers child tabs from complete metadata 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 actions = await renderActions(runtime, { + docMetaCacheReady: false, + sessionMetaCache: { [getSessionRoomId(rootSession.id)]: rootSession }, + }); sendIpcMock.mockClear(); await actions.archiveSession(rootSession.id); @@ -1205,6 +1219,73 @@ describe('useSessionActions', () => { } }); + it('fails archive without writes when complete metadata 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('restores child tabs without restoring independently opened session workspaces', async () => { const { rootSession, tabSession, openedSession, openedFromTabSession, sessionMetaCache } = createContainmentSessions('restore', true); diff --git a/specs/session-relations.md b/specs/session-relations.md index 915feaa84..07c445c55 100644 --- a/specs/session-relations.md +++ b/specs/session-relations.md @@ -37,7 +37,7 @@ behavior for malformed or legacy nested children. | 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. | +| Archive a Session | The selected Session and direct children whose `parentSessionId` equals its id. | Target discovery reads the repository metadata index directly. | | 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. | @@ -76,11 +76,11 @@ 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 reads the repository metadata index for every action, so an interactive root +does not depend on the client projection having discovered its direct children. The +query must complete before the first archive write, and query failure aborts the action +without mutation. Restore still discovers direct children from the client metadata +cache and therefore retains the cold-start implementation gap. ## Evidence From b0f71f3dde62e219561ccc8c5de688a69effa478 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:14:35 +0800 Subject: [PATCH 2/5] docs: link archive hydration fix PR Model: gpt-5 --- .../2026-09-13-session-archive-complete-metadata-query.md | 1 + .../2026-09-13-session-archive-complete-metadata-query.zh.md | 1 + 2 files changed, 2 insertions(+) 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 index 6f9439f88..abda95438 100644 --- 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 @@ -4,6 +4,7 @@ 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) 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 index 8afe81f79..5dc13002b 100644 --- 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 @@ -4,6 +4,7 @@ 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) From 54623883be77bd17f9dab18ef5a60cc2a9b156ef Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:16:15 +0800 Subject: [PATCH 3/5] fix(components): make archive cascade failure-safe Model: gpt-5 --- ...session-archive-complete-metadata-query.md | 37 +-- ...sion-archive-complete-metadata-query.zh.md | 22 +- .../src/hooks/use-session-actions.ts | 105 +++++++- .../tests/use-session-actions.test.ts | 232 ++++++++++++++++-- specs/session-relations.md | 28 ++- 5 files changed, 361 insertions(+), 63 deletions(-) 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 index abda95438..3daee56fa 100644 --- 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 @@ -1,4 +1,4 @@ -# Discover archive targets from complete repository metadata +# Make cold-start archive discovery and commit failure-safe Status: implemented Translation: current @@ -11,18 +11,19 @@ Implementation: [#658](https://github.com/LodyAI/Lody/pull/658) ## Abstract An interactive root Session could be archived before the client metadata projection -contained its direct child Tabs. Archive target discovery now reads the repository -metadata index for each action and performs no writes if that query fails. This avoids -both a partial-cache archive and an unbounded wait for global projection readiness, at -the cost of one metadata-index scan per archive action. +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 index lags that already-visible -document, but descendant discovery never falls back to the UI cache. +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 @@ -30,19 +31,27 @@ user action wait for that global signal would introduce an unbounded pending sta 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 starts with a UI cache containing only the root while the -repository index contains its direct child and independently opened Sessions. It -verifies that archive updates the root and child, leaves both independent Sessions -active, and closes only the two lifecycle-owned terminals. A failure case verifies that -an index-query error rejects before any archive metadata write. A deferred-query case -switches workspaces before discovery completes and verifies that the old runtime also -receives no write. +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 implements [#574](https://github.com/LodyAI/Lody/issues/574) and complements the containment decision recorded in 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 index 5dc13002b..d646f0177 100644 --- 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 @@ -1,4 +1,4 @@ -# 从完整仓库元数据中发现归档目标 +# 让冷启动归档的目标发现与提交具备失败安全性 Status: implemented Translation: current @@ -11,28 +11,36 @@ Contract: [Session 关系与操作目标](../../../../specs/session-relations.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 测试从一个只包含根 Session 的 UI 缓存开始,而仓库索引同时包含其直接子项和独立打开的 -Session。测试验证归档会更新根与子项、保留两个独立 Session,并且只关闭两个生命周期归属目标的终端。 -另一个失败用例验证元数据索引查询报错时,会在任何归档元数据写入之前拒绝操作。延迟查询用例会在 -发现完成前切换工作区,并验证旧 runtime 同样不会收到写入。 +所属 hook 测试通过生产 `getMeta().scan()` 路径执行归档:UI 缓存只包含根 Session,而仓库同时包含 +直接子项和独立打开的 Session。测试覆盖目标与终端集合、子项写入失败与最终根写入失败的补偿、 +元数据失败时不关闭终端,以及切换工作区的两个边界:首笔写入前中止,首笔写入后继续在捕获的 runtime +完成提交。 本次改动实现 [#574](https://github.com/LodyAI/Lody/issues/574),并补充 [让被打开的 Session 不受开启者状态级联影响](2026-09-10-session-containment-lifecycle.zh.md) diff --git a/packages/components/src/hooks/use-session-actions.ts b/packages/components/src/hooks/use-session-actions.ts index dd0afa6b6..155b1fb50 100644 --- a/packages/components/src/hooks/use-session-actions.ts +++ b/packages/components/src/hooks/use-session-actions.ts @@ -246,7 +246,7 @@ function getArchiveStateTargets( ]; } -async function listCompleteSessionMetadata(runtime: WorkspaceRuntime): Promise { +async function listSessionMetadataSnapshot(runtime: WorkspaceRuntime): Promise { const entries = await listDocMetaEntries(runtime.repo); return entries.flatMap((entry) => { if ( @@ -260,6 +260,76 @@ async function listCompleteSessionMetadata(runtime: WorkspaceRuntime): 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 attemptedTargets: 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. + attemptedTargets.push(session); + await runtime.writer.upsertDocMeta(getSessionRoomId(session.id), { + isArchived: true, + status: SessionStatusFactory.idle(), + } as Partial); + } + } catch (archiveError) { + const rollbackErrors: unknown[] = []; + const attemptedRoot = attemptedTargets.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) { + rollbackErrors.push(rollbackError); + } + } + + if (!attemptedRoot || rollbackErrors.length === 0) { + for (const session of [...attemptedTargets].reverse()) { + if (session.id === rootSessionId) continue; + try { + await runtime.writer.upsertDocMeta(getSessionRoomId(session.id), { + isArchived: session.isArchived, + status: session.status, + } as Partial); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + } + + if (rollbackErrors.length > 0) { + const failure = new Error( + `Archive failed and ${rollbackErrors.length} lifecycle rollback(s) also failed`, + { cause: archiveError } + ); + Object.assign(failure, { rollbackErrors }); + throw failure; + } + throw archiveError; + } +} + async function assertArchivedLocalProjectCanRestore( runtime: WorkspaceRuntime, sessionMeta: SessionMeta @@ -1183,15 +1253,15 @@ export function useSessionActions(): SessionActions { } const sessionRoomId = getSessionRoomId(sessionId); - const sessionMetadata = await listCompleteSessionMetadata(runtime); + 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); - // The complete repository index is preferred, but it can lag a Session + // The repository snapshot is preferred, but it can lag a Session // the UI already renders. The archive write below is an idempotent patch, // so rendered root metadata is enough to proceed. Descendant discovery - // still comes exclusively from the complete index above. + // still comes exclusively from the queried snapshot above. const sessionMeta = repoMeta ?? (store.get(sessionMetaCacheAtom)[sessionRoomId] as SessionMeta | undefined); if (!sessionMeta) { @@ -1203,16 +1273,25 @@ export function useSessionActions(): SessionActions { }); const archiveTargets = getArchiveStateTargets(sessionId, sessionMeta, sessionMetadata); - for (const session of archiveTargets) { - if (typeof window !== 'undefined') { - sendIpc('terminal.closeSession', { sessionId: session.id }); + // The first write is the commit boundary. From here the captured runtime + // must finish the old-workspace write set or compensate it; switching the + // active workspace cannot redirect or cancel an in-flight commit. + await writeArchiveStateFailureSafe(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, diff --git a/packages/components/tests/use-session-actions.test.ts b/packages/components/tests/use-session-actions.test.ts index 05aafd97f..54d33d8f1 100644 --- a/packages/components/tests/use-session-actions.test.ts +++ b/packages/components/tests/use-session-actions.test.ts @@ -3,6 +3,7 @@ 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, @@ -1186,40 +1187,185 @@ describe('useSessionActions', () => { ); }); - it('discovers child tabs from complete metadata before the UI cache hydrates', 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 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('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, { - docMetaCacheReady: false, sessionMetaCache: { [getSessionRoomId(rootSession.id)]: rootSession }, }); sendIpcMock.mockClear(); - await actions.archiveSession(rootSession.id); + await expect(actions.archiveSession(rootSession.id)).rejects.toThrow('child archive failed'); - for (const session of [rootSession, tabSession]) { - expect(metaRepo.getSession(session.id)).toMatchObject({ - isArchived: true, - status: { type: 'idle' }, - }); - } - for (const session of [openedSession, openedFromTabSession]) { - expect(metaRepo.getSession(session.id)).toMatchObject({ isArchived: false }); - } + 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(); + }); - expect(sendIpcMock.mock.calls).toEqual([ - ['terminal.closeSession', { sessionId: rootSession.id }], - ['terminal.closeSession', { sessionId: tabSession.id }], + 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(runtime.writer.flockRowPut).not.toHaveBeenCalled(); - for (const session of [rootSession, openedSession, openedFromTabSession]) { - expect(metaRepo.getMeta(getMachineRoomId(session.machineId))).toBeUndefined(); - } + 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' }, + rollbackErrors: [{ 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 complete metadata cannot be read', async () => { + 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, @@ -1286,6 +1432,50 @@ describe('useSessionActions', () => { 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 }], + ]); + }); + it('restores child tabs without restoring independently opened session workspaces', async () => { const { rootSession, tabSession, openedSession, openedFromTabSession, sessionMetaCache } = createContainmentSessions('restore', true); diff --git a/specs/session-relations.md b/specs/session-relations.md index 07c445c55..3eb3a3d27 100644 --- a/specs/session-relations.md +++ b/specs/session-relations.md @@ -35,17 +35,27 @@ 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 reads the repository metadata index directly. | -| 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 metadata writes have no cross-document transaction. Direct children must be +written before the root, making the root the final commit point. A failed write must +attempt to compensate every attempted target to its pre-action lifecycle state. Root +compensation precedes child compensation; if it fails, children remain archived and the +write and rollback errors are surfaced together, preserving the root-archived +implication. Terminal closure starts only after all metadata writes succeed; a metadata +failure therefore closes no target terminal. Once the first write starts, the action +remains pinned to the captured workspace runtime until the write set commits or +compensation finishes. + 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 @@ -79,8 +89,10 @@ remain separate in [#529](https://github.com/LodyAI/Lody/issues/529). Archive reads the repository metadata index for every action, so an interactive root does not depend on the client projection having discovered its direct children. The query must complete before the first archive write, and query failure aborts the action -without mutation. Restore still discovers direct children from the client metadata -cache and therefore retains the cold-start implementation gap. +without mutation. Its result is complete for the repository snapshot observed by that +query; it is not a transaction boundary and does not include children created after the +snapshot. Restore still discovers direct children from the client metadata cache and +therefore retains the cold-start implementation gap. ## Evidence From eb0c3e109ecdf91c8eabe5403204d8c952f29a42 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:26:59 +0800 Subject: [PATCH 4/5] docs: plan atomic session lifecycle commits Propose immutable lifecycle operations, durable recovery, and atomic metadata projection to replace archive snapshot compensation. Add the bilingual contract and decision note, implementation gates, and a reproducible dependency probe. Validation: dependency probe, targeted Prettier, and diff checks pass. Full pnpm check and format cannot start because corepack is unavailable. Docs check retains 20 pre-existing links into missing submodules. Model: gpt-5 --- .../2026-09-13-session-lifecycle-commit.md | 129 +++++++ .../2026-09-13-session-lifecycle-commit.zh.md | 65 ++++ plans/001-session-lifecycle-commit.md | 342 ++++++++++++++++++ plans/README.md | 24 ++ .../support/inspect-lifecycle-boundaries.cjs | 133 +++++++ specs/session-relations.md | 94 ++++- specs/session-relations.zh.md | 87 +++++ 7 files changed, 858 insertions(+), 16 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md create mode 100644 .agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.zh.md create mode 100644 plans/001-session-lifecycle-commit.md create mode 100644 plans/README.md create mode 100644 plans/support/inspect-lifecycle-boundaries.cjs create mode 100644 specs/session-relations.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..3b59e3ef9 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md @@ -0,0 +1,129 @@ +# Commit Session lifecycle operations as one durable fact + +Status: proposed +Translation: current + +Contract: [Session relations](../../../../specs/session-relations.md) +Execution: [Implementation plan](../../../../plans/001-session-lifecycle-commit.md) + +[中文](2026-09-13-session-lifecycle-commit.zh.md) + +## Abstract + +Archive currently 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 proposed replacement 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, but also expose non-atomic repository observation and +per-key conflict resolution. Production persistence and mixed-client migration remain +unverified, so this is a proposal rather than a completed fix for #574. + +## 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. + +## 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 [reproducible baseline probe](../../../../plans/support/inspect-lifecycle-boundaries.cjs) +checks rollback and repository publication against those pinned versions. + +| 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 chosen operation ordering, storage admission before publication, migration +baseline, and old-writer policy are gates before production path replacement in the +plan. Both IndexedDB and SQLite admission/recovery prototypes must pass before that +switch; proving one adapter is insufficient. 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; +do not claim their migration or silently enable 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 + +No production implementation or two-product-client persistence/restart test has been +completed for this proposal. The execution plan requires deterministic regression, +two-repository observation, concurrent operations, durable recovery, and legacy-client +fixtures before activation. Worktree cleanup and terminal disposal are evaluated +against effective committed state and do not make lifecycle commits reversible. + +[#658](https://github.com/LodyAI/Lody/pull/658) is the affected implementation, not a +completed replacement PR. [#574](https://github.com/LodyAI/Lody/issues/574) remains open +until the operation contract and migration gates have evidence. Local documentation +checks currently also report pre-existing 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..66a303f0a --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.zh.md @@ -0,0 +1,65 @@ +# 将 Session 生命周期操作作为一个持久化事实提交 + +Status: proposed +Translation: current + +契约:[Session 关系](../../../../specs/session-relations.md) +执行:[实现计划](../../../../plans/001-session-lifecycle-commit.md) + +[English](2026-09-13-session-lifecycle-commit.md) + +## 摘要 + +当前归档通过恢复先前读取的快照来补偿失败的元数据写入,这可能覆盖合法的并发写入,也可能自身只留下部分目标的变更。提议的替代方案将每次归档或恢复记录为一个不可变操作,并通过共享的仓库投影推导有效的 Session 生命周期状态。操作是冲突解决、持久化和发布的单位;资源清理跟随所得状态。真实依赖探针确认本地 WASM 的回滚行为,但也暴露出非原子仓库观察和按键冲突解决。生产持久化和混合客户端迁移仍未验证,因此这是针对 #574 的提案,而不是已完成的修复。 + +## 决策与范围 + +保留基于仓库的选定 Session 及其直接 `parentSessionId` 子级发现、明确的工作区所有权和提交后的终端清理。以仓库生命周期命令替代 `writeArchiveStateFailureSafe`、依赖顺序的写入、`attemptedTargets`、旧值补偿和回滚错误聚合。归档和恢复一起迁移,因为二者写入同一个权威来源。 + +一个操作冻结其目标 id、期望的归档状态、稳定身份和排序信息。其载荷在重试间不可变。共享解析器按完整操作排序,并发布一个有效元数据修订;它绝不为每个目标持久化一个独立的权威标志。独立 Tab 操作仍然有效,并使用相同的操作模型及单元素目标集合。执行状态仍由运行时拥有,绝不从生命周期快照恢复。 + +每个目标采用覆盖它的最高顺序操作。冻结集合相同的根操作一起选择同一胜者;集合不同时,较新操作未包含的目标保留此前结果。这是确定的操作优先级,不承诺所有子级始终与根同状态。 + +持久化准入包含崩溃安全的本地顺序分配,并在接受新命令前恢复已接纳但尚未发布的记录。发布和恢复允许重复交付同一个操作或修订;订阅者与资源副作用采用幂等处理,而不是声称通知恰好一次。资源处理还必须避免销毁恢复后产生的新 runtime 代次。 + +这是生命周期专用协议,不是通用 saga、命令队列或分布式数据库事务框架。兼容客户端仍针对自己的仓库本地写入。公共桌面端不引入 daemon 代理写入者、认证云端要求或托管实现。 + +## 依赖证据 + +检查使用了 Lody commit `54623883be77bd17f9dab18ef5a60cc2a9b156ef`,以及匹配 `loro-repo@0.20.0` 和 `@loro-dev/flock-wasm@0.4.3` 的已安装产物。使用的合成 Node 探针基于内存副本;没有操作产品 Session 数据。 + +[可复现的基线探针](../../../../plans/support/inspect-lifecycle-boundaries.cjs)针对这些固定版本检查回滚与仓库发布行为。 + +| 边界 | 观察到的行为 | 后果 | +| ------------------ | ----------------------------------------------------------------------------- | --------------------------------------------------- | +| 元数据存储 | 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。 + +选定的操作排序、发布前的存储准入、迁移基线和旧写入者策略,是计划中替换生产路径前的门槛。切换前 IndexedDB 和 SQLite 的准入/恢复原型都必须通过;只证明一个 adapter 不够。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 记录其历史实现;它不构成对本替代方案的批准。 + +## 验证与发布 + +本提案尚未完成生产实现,也没有完成两个产品客户端的持久化/重启测试。执行计划要求在启用前完成确定性回归、双仓库观察、并发操作、持久恢复以及遗留客户端 fixture。Worktree 清理和终端处置依据有效提交状态评估,不会使生命周期提交可逆。 + +[#658](https://github.com/LodyAI/Lody/pull/658) 是受影响的实现,而不是已完成的替代 PR。在操作契约和迁移门槛获得证据前,[#574](https://github.com/LodyAI/Lody/issues/574) 仍保持开放。本地文档检查目前也报告了指向未初始化 ACP 子模块的预先存在链接;这些发现与实现验证无关。 diff --git a/plans/001-session-lifecycle-commit.md b/plans/001-session-lifecycle-commit.md new file mode 100644 index 000000000..93c46a7f1 --- /dev/null +++ b/plans/001-session-lifecycle-commit.md @@ -0,0 +1,342 @@ +# Plan 001:以原子操作替代 Session archive 补偿 + +> 执行 Agent:先完整阅读本计划,再按阶段实施。每阶段返回 diff、实际命令和可复现结果; +> 主控负责架构取舍与最终验收。本计划没有授权 push、修改 PR、关闭 Issue 或启用不兼容协议。 +> 先执行下面的漂移检查;发生漂移时对照现有代码与摘录修订计划,不能机械套用行号。 + +## 状态与目标 + +- Priority: P1;Effort: L;Risk: HIGH;Category: correctness / architecture。 +- Planned at: `54623883be77bd17f9dab18ef5a60cc2a9b156ef`,2026-09-13。 +- Depends on: 无其他计划;生产启用依赖本计划中的存储与兼容性门槛。 +- 工作对象:[Issue #574](https://github.com/LodyAI/Lody/issues/574) 与 + [PR #658](https://github.com/LodyAI/Lody/pull/658)。当前分支为 `fix/archive-hydration-bounded-wait`。 +- Intent:[Session relations](../specs/session-relations.md)。 +- Rationale:[原子 lifecycle 提交提案](../.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md)。 + +一次 root archive 或 restore 必须以同一个操作覆盖它的冻结目标集。提交前失败不能留下 +本次变更;提交后的确认失败不能恢复旧快照。兼容副本可以在不同时间收到操作,但同一 +有效状态快照不能暴露该操作只应用了一部分。单独归档 Tab 仍合法。 + +## 当前实现与可保留部分 + +| Owner | 当前职责与问题 | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `packages/components/src/hooks/use-session-actions.ts` | repository discovery 已修正冷启动漏 child;archive 仍逐条写入和补偿,restore 仍查 UI cache。 | +| `packages/components/src/providers/workspace-writer{,-impl}.ts` | 所有 renderer 本地 author;`upsertDocMeta` 只转发单次 repo 调用。 | +| `packages/components/src/atoms/doc-meta.ts` | 本地 patch 逐 doc 立即发布;远端 patch 有大小限制的分批发布。 | +| `apps/cli/src/commands/session.ts` | CLI archive/restore 对 root 和 children 并行调用单 doc writer;MCP archive 复用该命令。 | +| `apps/cli/src/lib/message-handler.ts` | 观察单 Session archive 后释放 runtime;另有 local-project removal 的逐条 archive writer。 | +| `packages/shared/src/schema.ts` | `SessionMeta.isArchived` 是可选布尔值,尚无 lifecycle operation 契约。 | +| `patches/loro-repo.patch`、`pnpm-lock.yaml` | 固定依赖与补丁;现有 patch 只修复 metadata live monitor 启动。 | + +漂移检查: + +```sh +git diff --stat 54623883be77bd17f9dab18ef5a60cc2a9b156ef..HEAD -- packages/components packages/shared apps/cli patches/loro-repo.patch pnpm-lock.yaml specs/session-relations.md +git status --short +``` + +关键现状摘录,来自 `use-session-actions.ts:280` 与 `:311`: + +```ts +attemptedTargets.push(session); +await runtime.writer.upsertDocMeta(getSessionRoomId(session.id), { + isArchived: true, + status: SessionStatusFactory.idle(), +}); +// 失败后的另一次 authored write: +await runtime.writer.upsertDocMeta(getSessionRoomId(session.id), { + isArchived: session.isArchived, + status: session.status, +}); +``` + +第二段的值来自先前 snapshot,不代表当前值由本操作拥有。失败期间另一个 writer +修改状态后,这段补偿会产生新的 CRDT 写入覆盖它。 + +保留 repository discovery 的思路、仅 direct `parentSessionId` 的目标规则、workspace +切换边界、独立 `openedBy*` Session 不受影响、提交后 best-effort terminal cleanup。 +删除 snapshot compensation、children/root 排序作为正确性基础、`attemptedTargets`、 +`rollbackErrors`,以及 rendered cache 作为生命周期提交依据。 + +## 已知依赖能力,不得扩大解释 + +`loro-repo@0.20.0` 的 metadata 使用 `@loro-dev/flock-wasm@0.4.3`,字段位于同一个 +meta Flock 的 `m/docId/field` key。真实内存实验已观察到 WASM 同步 transaction 抛错时 +撤销数据、事件和导出变更,但 `@loro-dev/flock@4.4.4` 的同名 API 不撤销数据。 +WASM 文档与二进制还存在该语义差异,必须用行为测试锁住具体版本。 + +`getMeta().txn` 不能直接替代应用 writer:原始值改变后,LoroRepo 的已加载 cache +仍可保持旧值;远端整批 import 又会逐 doc reconcile,向 watcher 暴露中间状态。 +字段各自拥有 CRDT 时钟,多 key transaction 没有整体冲突决胜保证。 +`upsertDocMeta` 不等待落盘;`persistMetaNow` 是另一个边界。 + +可复现的基线探针:[inspect-lifecycle-boundaries.cjs](support/inspect-lifecycle-boundaries.cjs)。 +在已安装依赖的 clone 中运行: + +```sh +node plans/support/inspect-lifecycle-boundaries.cjs packages/components/package.json +``` + +它应 exit 0,并报告 `peerUpdatePreserved: true`、`exportUnchanged: true`、本地 raw/cache +分别为 `[true,true]` / `[false,false]`,远端 watcher 曾看到 child/root 不一致。 +这些断言描述旧依赖的局限,不是修复验收;升级后行为变化必须重新评估。 +可传另一个显式 package.json 路径定位已安装的同版本依赖,不安装或修改该依赖目录。 + +Frontend runtime 在 `create-workspace-runtime.ts:416` 显式设置 +`metaDebounceCommitMs: 0`。默认 debounce 与 `txn` 互斥不是这个 runtime 的直接阻碍。 +事务内禁止 async、Promise 回调和 import;import 可能先提交再报错。 + +## 选择的操作模型 + +以一条完整、不可变的 JSON record 表达一次操作,存储 key 由 repository adapter 管理。 +以下是待原型验证的 v1 数据形状,字段名称可以在同一阶段调整,语义不可省略: + +```ts +type SessionLifecycleOperation = { + version: 1; + operationId: string; + subjectId: SessionId; + targetIds: readonly SessionId[]; + state: 'archived' | 'active'; + order: { counter: string; actorId: string }; +}; +``` + +- `targetIds` 来自同一次 repository discovery,去重后冻结,必须包括 subject。 + root 操作包含所发现 direct children;Tab 单独操作只有该 Tab。不包含 `openedBy*`。 +- 使用单 key、完整 JSON value 的写入。禁止把 record 用自动展开对象的 API 拆成多个 key, + 也禁止靠多 key 的 `pending/committed` 标记拼出未经证明的原子性。 +- 操作按不可变 id 保留,不能让一个可变 root record 的整对象替换丢掉尚未同步的操作。 + 重试复用相同 id、排序和 payload;同 id 不同内容属于协议冲突,不能任选一份继续。 +- 提议采用 Lamport 顺序:新操作的 counter 大于当前已观察到和本地已预留的 counter; + 序列化为规范非负十进制字符串,用数值比较,禁止按字符串排序或依赖墙钟。 + 同 counter 时按 actorId、operationId 的稳定字节序决胜。重试绝不提升排序。 + 同一 workspace/store 的 counter 分配与 record 持久化接纳必须串行化;共享存储的多个 + tab/process 需要存储事务或等价协调,不能仅靠进程内变量。启动先恢复已发布与已接纳 + 未发布记录的 high-water mark,再开放新命令;不能只扫描已发布的 Flock 状态。 +- resolver 对每个目标选取覆盖它的最高顺序操作;一个 root 操作的排序对所有目标相同。 + 固定目标集上的两个 root 操作整体决胜,较新的 singleton Tab 操作只覆盖该 Tab。 + 反过来,较新的 root 操作覆盖它所包含的旧 singleton。两个 root 操作的冻结集合不同 + 时,共有目标由较新操作决定,不在较新集合的目标保留最后一个覆盖它的操作结果。 + 这是明确的操作顺序,不承诺对未观察到的远端操作具备真实时间线性一致性。 +- 从完整 record 计算所有受影响目标,再一次发布有效 metadata revision。`isArchived` + 是该投影的结果,不能同时保留另一套独立 author 的权威 flags。 +- lifecycle 操作不写回或伪造 runtime `status`;实际终止后由既有 runtime owner 发布 idle。 + 初次创建 Session 的未归档初始化是 baseline,不等同于 archive/restore 命令。 +- deletion/existence 优先:operation 不创建、不复活已删除或未知的 Session。冻结目标里 + 尚未到达的 metadata 可以保持未知,之后 hydration 必须使用同一个 resolver。 +- v1 不清理 operation 历史。建立按目标索引与增量投影;不在每次组件 render 全量重放。 + 记录增长成本与后续 checkpoint 条件,不能在没有离线副本保留契约时按时间删除记录。 + +这个模型需要原型与契约评审后才能冻结 wire format。它限定于 archive/restore,不能扩成 +通用 Operation 调度器、worker supervision 或新的云端服务。 + +## 提交、持久化与恢复 + +```text +discovery / validation + -> durable admission of one immutable record + -> publish effective revision and replicate + -> reconcile current resource state +``` + +repository adapter 必须说明谁持久化 record、谁允许它进入实时投影和同步流、失败由谁接管。 +优先复用具备提交前隔离与落盘边界的 repository 能力;当前 `put; await persistMetaNow()` +会先发事件,不能直接当成已证明的 durable admission。 + +若依赖不能延迟发布,需要受测试保护的 dependency 扩展,或在既有本地存储中原子写入 +该 record 的专用 admission journal,再按同 id 发布。journal 只保存生命周期提交依据, +不是回滚快照或另一套任务调度队列。必须先完成 IndexedDB/SQLite 适配原型与恢复测试, +才确定生产存储布局;不要让执行 Agent 猜测底层 transaction 的保证。 + +| 失败位置 | 必需结果 | +| ------------------------------------ | ------------------------------------------------------------------ | +| 验证或持久化接纳之前,且确认没有接纳 | 没有本次有效状态、同步更新、terminal effect;可报告 rejected。 | +| 存储调用结果不确定 | 返回可查询的同一 operationId;不能谎称零写入,也不能 author 补偿。 | +| record 已持久化,发布或回复失败 | 返回/恢复同一提交;启动重放、重连、重复请求都不新建操作。 | +| 同步或资源清理失败 | 保留 lifecycle 事实,由同步/资源 owner 重试,不恢复旧状态。 | +| 本操作已被较新操作覆盖 | 保留历史身份;重试不提升排序,不重放过期 teardown。 | + +进程重启只能恢复已经持久化的依据。存储不可用期间不能承诺尚未落盘的操作跨重启存活。 +UI 导航、terminal cleanup 和 CLI 成功响应以明确的 durable receipt 为依据;同步确认另行 +呈现。发布与重放采用至少一次交付:同 operation/revision 可重复通知,不能承诺跨崩溃 +exactly-once 事件。订阅携带稳定 operation 身份与可识别的 lifecycle revision;重复交付 +不能成为新逻辑操作、提升排序或重复破坏资源。分别测试落盘后发布前、发布后记录完成前 +崩溃;恢复时按完整已知操作集重算当前 revision,不强制逐条通知已经过期的中间态。 + +watcher 和资源执行点重读当前有效状态,并与同一 Session 的 start/resume 协调:排队的 +旧 archive 不得作用于 restore 后的新 runtime generation。跨 await 的 teardown 必须 +绑定捕获的资源实例/代次或使用等价串行化约束;只在开始时检查一次布尔值不足以证明安全。 + +## 模块边界与修改范围 + +| 层 | 计划修改范围 | 责任 | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| 共享契约 | `packages/shared/src/session-lifecycle.ts`、`packages/shared/tests/session-lifecycle.test.ts`(新增),必要的 exports/schema | parser、排序、纯 resolver、结果类型;平台中立。 | +| repository 接缝 | `packages/shared/src/session-lifecycle-repository.ts`、`packages/shared/tests/session-lifecycle-repository.test.ts`(新增);必要的 `patches/loro-repo.patch`、catalog/manifest/lock | 平台中立的接纳、恢复和完整 revision 发布 owner;注入实际存储 port。 | +| IndexedDB 持久化 | `packages/components/src/lib/session-lifecycle-persistence.ts`、`packages/components/tests/session-lifecycle-persistence.test.ts`、`packages/components/tests/e2e/session-lifecycle-persistence.spec.ts`(新增) | 使用实际 browser 存储验证 durable admission 和跨 reload 恢复;不以 fake IndexedDB 作为最终证据。 | +| SQLite 持久化 | `apps/cli/src/lib/loro/session-lifecycle-persistence.ts`、相邻 `session-lifecycle-persistence.test.ts`(新增),必要时扩展 `sqlite-repo-store.ts` | 沿用隔离 workspace 存储命名空间,验证真实 SQLite 接纳、关闭重开与 replay。 | +| renderer | `workspace-writer.ts`、`workspace-writer-impl.ts`、`create-workspace-runtime.ts`、`atoms/runtime.ts`、`atoms/doc-meta.ts`、`use-session-actions.ts` | 注入统一 owner,替换 archive/restore,原子更新 cache;释放 workspace 时保留已接纳责任。 | +| CLI/runtime | `apps/cli/src/commands/session.ts`、`lib/loro/doc.ts`、`lib/message-handler.ts`、`session/session-dispatch-watcher.ts`、`session/session-execution-service.ts` | 命令与 local-project removal 走同一 writer;查询、dispatch、resume、GC 读有效状态。 | +| 其他消费者 | `providers/background-sync-coordinator.ts`、CLI list/show、MCP summaries、归档 UI 的必要读取接缝 | 接收统一投影,不在各消费者复制 resolver。 | +| 测试与文档 | 上述 owning suites、`specs/session-relations*`、本提案、受影响 README/AGENTS | 行为证据和正确的实施状态。 | + +遵守根和各 scoped AGENTS;涉及 protocol capability 时读 `packages/shared/AGENTS.md`。 +shared 不依赖私有包或 hosted API。每个客户端继续 author 自己的 repo,不恢复 daemon proxy。 +不要逐组件添加 fallback;通过明确的 repository metadata reader 契约让现有消费者获得 +统一结果,并在迁移清单中核实所有 raw reader。访问 raw fields 的必要场景必须显式命名。 + +不在范围内:永久删除的事务化、nested child、snapshot 后创建 child 的完整性保证、#529、 +worktree GC 策略重写、真实用户数据迁移实验、私有 Web/mobile 源码、运营准入配置。 +实现发现必须修改新的 owner 时,先更新此处具体范围与理由,再由主控判断。 + +## 执行阶段与验证 + +### 1. 固定依赖能力与失败证据 + +在 owning writer/cache suite 中建立真实 WASM 与 LoroRepo 的确定性 fixture,保留现有 cold-start +discovery 用例。使用可释放 Promise gate、注入时钟与 synthetic metadata,不用真实 sleep。 +先记录旧实现的两个反例:第三方更新后 pre-mutation reject 覆盖状态,以及 child 补偿失败。 +另测 raw txn/cache 不一致和远端逐 doc publication,避免修复只通过 fake repo。 + +运行下方组件命令。旧实现应在指定新 regression 上失败;已有行为测试仍通过。 +提交证据必须能区分 baseline 失败与测试环境缺依赖。随后实现应让这些 regression 转绿, +不能删除断言以获得通过。 + +### 2. 验证操作模型、两种存储与切换门槛 + +新增共享 parser/resolver 测试,覆盖验收矩阵中的排序、成员集、未知与删除目标。 +按上述明确路径分别做 IndexedDB 和 SQLite 最小持久化原型;两者都注入写前失败、已存后 +返回失败、发布前重启、发布后重放,不能只证明第一个 adapter。验证并发本地接纳的 counter +分配,以及已接纳未发布后重启、新操作的顺序。证明 reader 只看到完整 revision,并保留 +第三方无关 metadata 和 status。冻结 record keyspace、完整 JSON 编码、API/result、order +和本地持久化布局,写回 owning Note。 + +在接入生产之前确定可执行的 baseline/旧 writer 策略及启用机制。明确哪些拓扑可以协调 +切换,哪些仍被阻断;用遗留 writer 与离线重连 fixture 验证,而不是只写一个 feature flag。 +这个阶段的新路径保持未启用,现有生产路径暂留且仍标记为未修复;不得先删除旧路径, +之后才发现新协议无法启用。没有一条长期双权威的过渡实现。 + +运行 shared、两种 persistence 与组件命令,全部通过。浏览器用 owning Playwright suite 的 +Vite 模块加载方式运行真实 adapter;隔离数据库名,关闭/reload 后重建 owner,不模拟落盘。 +单 record 已足够时不另造通用多 doc transaction API。 +若依赖修改必要,需附源码来源、版本、生成方式和发布/patch 路径;禁止只改 node_modules。 +不能完成这个阶段的原子性、两种持久性与可执行切换证明时,不开始切换生产调用点。 + +### 3. 迁移同一权威的生产者与消费者 + +同时替换 renderer/CLI archive 与 restore;MCP archive 复用 CLI,无需新增远程 author。 +local-project removal 保留原有重试/资源责任,但每个生命周期操作走新 writer。 +初始未归档 baseline 保留,普通 status producer 不参与旧值恢复。 + +本地与远端投影都整批安装再通知。把 `getDocMeta`、list/scan、watch、UI atom、daemon +dispatch/resume/GC 的读取接缝逐一纳入一致性测试,不能只改变 sidebar 的显示。 +先在满足阶段 2 门槛的隔离拓扑中协调切换所有 writer/reader,再移除该拓扑的旧路径; +在授权的生产发布前完成阶段 4 验收。删除旧 helper 及依赖补偿顺序的断言,以提交结果、 +最终状态与可观察 revision 代替。若支持的拓扑尚不能一起迁移,不发布一个缺失可用路径的 +中间版本,也不能把保留旧路径的拓扑记为已修复。 + +运行组件、shared、CLI owning suites 与公共边界检查。`rg` 检查旧 helper 应零命中; +剩余 `isArchived` 写入逐处分类为初始化、兼容性入口或错误绕过,不能仅凭字符串数量验收。 + +### 4. 兼容性与产品验收后启用 + +复核阶段 2 已证明的切换机制:旧布尔值必须在明确的迁移边界成为 baseline。新格式启用后,不得继续接受无法表达同一 +操作的旧 flags 为并列权威。需要在实际 writer 准入边界处理旧 renderer、daemon 和离线重连, +或者有覆盖所有参与者的协调升级方案与证据。缺少该机制时保持新格式未启用,报告缺口。 + +`MachineMeta.protocolCapabilities` 只描述 daemon,不能证明所有 renderer 都支持新格式。 +当前公开源码没有足够机制证明产品所有客户端已迁移。本计划不授权修改 hosted 准入服务。 +本地 OSS 拓扑和产品多客户端拓扑分别验收,不能互相代替。 + +先执行 owning suites 和全量 checks,再用两个隔离 runtime、真实持久化与同步通道验收: +故障 gate 保证动作交错,重启复用隔离数据目录,结果用结构化状态而不是 toast 判定。 +覆盖既有 `LODY-SESSION-004` target/discovery 行为;新的故障用例放入 owning journey/fixture, +即 `e2e/src/features/session-management.feature`、 +`e2e/src/support/fixtures/session-relation-lifecycle-fixture.ts` 和 +`e2e/src/support/pages/session-relation-lifecycle-page.ts`。需要新 scenario 时同步 +`e2e/journeys/registry.json`,遵循 `e2e/AGENTS.md`。禁止使用真实 workspace 数据。 +该桌面验收证明 OSS 拓扑,不能替代私有 Web/mobile 的产品发布证据。 + +## 验收矩阵 + +| 场景 | 必须观察到的结果 | +| ----------------------------------------------- | ---------------------------------------------------------------------------- | +| snapshot 后另一 writer 改 child,本次提交前失败 | 另一方的 archived/status/无关字段保持;没有本次导出变更和事件。 | +| 第 N 个目标计算或验证失败 | 未持久化/发布任何部分 operation;不存在 child rollback 需求。 | +| durable write 已接受但返回失败 | 查询同 id 与重启确认同一提交;允许重复交付,不产生新操作、新排序或重复破坏。 | +| durable record 后、projection 前崩溃 | 重启完整重建所有目标;没有需要人工修复的 flags 分裂。 | +| projection 后、完成标记前崩溃 | 同操作 replay 幂等;所有订阅与资源效果能处理重复通知。 | +| 本地并发接纳、接纳未发布后重启再发命令 | 分配与持久化串行;新 counter 高于所有本地已接纳及已观察值。 | +| 已落后于新 restore 的 archive 重放 | restore 不被覆盖,旧 teardown 不执行。 | +| teardown await 期间 restore 并启动新 runtime | 旧任务不销毁新资源代次;检查发生在真实异步资源边界。 | +| 两个 root archive/restore 并发,固定成员集 | 合并顺序与重复交付不改变全体目标的同一胜者。 | +| root 操作与较新 singleton Tab 操作 | 只有 Tab 被后者覆盖;其他目标一致,root worktree 不受 Tab 单独操作支配。 | +| 旧 singleton 后收到较新 root 操作 | root 操作覆盖该 Tab;不存在 singleton 永久压过父操作的特殊规则。 | +| 相同 counter、不同 actor/id;重复和反向交付 | 所有副本遵循同一稳定比较顺序;最终状态与到达顺序无关。 | +| 两个 root 操作使用不同冻结集合 | 共有目标取较新操作;集合外目标保留最后覆盖值,不补写未选中的目标。 | +| 本地/远端 watch、get/list 与 UI revision | 每个已发布 revision 都来自完整操作集,不暴露逐目标安装中间态。 | +| cache 缺 root、只渲染了 root、workspace 切换 | 权威查询决定是否可提交;已接纳操作不被新 workspace 接管。 | +| 旧 writer、离线回归、未知 schema version | 按明确兼容策略拒绝/隔离或迁移;不能静默双写降级。 | +| 同步中断、runtime/terminal 清理失败 | durable operation 保持,恢复后收敛;无 metadata compensation。 | + +## 命令与环境 + +Node 22+,pnpm 使用根 `package.json` 固定版本。当前 nested worktree 没有 node_modules, +组件测试曾因 `vitest: command not found` 未执行。不要在 nested checkout 安装;需要完整 +workspace 验证时使用独立 clone 并按仓库要求初始化授权的 submodules、安装固定依赖。 +借用恰好匹配的依赖只能用于说明其版本的 isolated probes,不能冒充全量 typecheck。 + +| 用途 | 命令 | 成功结果 | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| 开始文档检查 | `pnpm run docs status` | 记录 baseline;当前有 20 个未初始化 submodule 链接错误。 | +| 组件行为 | `NODE_ENV=test pnpm --dir packages/components test tests/use-session-actions.test.ts tests/workspace-writer.test.ts tests/doc-meta-subscription.test.ts tests/doc-meta-batch.test.ts` | 修复完成后所有用例通过。 | +| 共享模型与 owner | `pnpm --dir packages/shared test tests/session-lifecycle.test.ts tests/session-lifecycle-repository.test.ts` | 新增模型与恢复行为全部通过。 | +| IndexedDB 接缝 | `NODE_ENV=test pnpm --dir packages/components test tests/session-lifecycle-persistence.test.ts` | 注入故障与 API 契约通过,不替代真实浏览器存储。 | +| 浏览器实际持久化 | `pnpm --dir packages/components test:e2e tests/e2e/session-lifecycle-persistence.spec.ts` | 真实 IndexedDB 的失败、重复交付和 reload 恢复通过。 | +| SQLite 实际持久化 | `pnpm --dir apps/cli test src/lib/loro/session-lifecycle-persistence.test.ts src/lib/loro/sqlite-repo-store.test.ts` | 隔离 SQLite 的失败与关闭重开恢复通过。 | +| CLI 资源边界 | `pnpm --dir apps/cli test tests/message-handler-terminal-cleanup.test.ts tests/worktree-gc.test.ts` | 原有资源语义与新恢复行为通过。 | +| CLI 命令 | `pnpm --dir apps/cli test src/commands/session.test.ts` | 既有 archive/restore 与新 writer 结果契约通过。 | +| 实际 repo 同步边界 | `pnpm --dir apps/cli test tests/loro-native-multi-transport.integration.test.ts tests/loro-doc-unload-data-plane-integration.test.ts` | 双副本 owning suite 与新增故障、恢复场景通过。 | +| 类型 | `pnpm --dir packages/shared typecheck`、`pnpm --dir packages/components typecheck`、`pnpm --dir apps/cli typecheck` | 正确依赖环境中 exit 0。 | +| 公共边界 | `pnpm check:public-boundary` | exit 0;无 private/cloud/local 边界变化。 | +| E2E 定义 | `pnpm e2e:check` | journey registry、scenario 与 fixture 一致。 | +| 桌面产品场景 | `pnpm e2e:build` 后 `pnpm --dir e2e exec cucumber-js --config cucumber.mjs --tags '@LODY-SESSION-004'` | 隔离的真实桌面/CLI 运行时完成目标、故障与恢复验收。 | +| 桌面 smoke | `pnpm e2e:smoke` | 构建后既有 P0 场景通过。 | +| 提交前 | `pnpm check`、`pnpm format`、`git diff --check` | 完整检查通过;review formatter 实际 diff,保留无关用户修改。 | +| 完成文档检查 | `pnpm run docs check` | 不增加 baseline 错误;可用 submodule 环境下应 exit 0。 | + +新建的测试路径必须在所属阶段实现后才能运行。最终旧 helper 检查应无命中(`rg` exit 1): + +```sh +rg -n 'writeArchiveStateFailureSafe|attemptedTargets|rollbackErrors' packages/components/src/hooks/use-session-actions.ts +``` + +测试风格参考 `workspace-writer.test.ts` 的 Promise gate 与状态断言,以及 +`doc-meta-subscription.test.ts` 的真实 LoroRepo 双副本 fixture。沿用 fixture 思路,替换其中 +依赖真实 timer 的等待,不复制浅层 mock-call-only 断言。 +`sqlite-repo-store.test.ts` 已使用真实临时 SQLite,但尚无故障注入证明; +`loro-doc-unload-data-plane-integration.test.ts` 有真实 repository/storage 与可控传输边界。 +浏览器 owning suite 的 Vite 加载可参考 `tests/e2e/terminal-theme.spec.ts`,使用实际存储 adapter, +不能以一次模拟的 unavailable error 代替持久化验证。浏览器 adapter 测试不冒充桌面产品 E2E。 + +## 完成条件与停止条件 + +- [ ] acceptance matrix 均有 owning test 或实际 runtime 证据,结果注明版本与存储 adapter。 +- [ ] 提交前失败零影响;不确定结果有稳定身份;重试和重启不提升旧操作优先级。 +- [ ] 生产者与消费者使用同一权威,原始 metadata 与有效投影的界限可审计。 +- [ ] 新格式的全体 writer 兼容性有证据;没有仅凭 daemon capability 推断 renderer 兼容。 +- [ ] #574 的 target/discovery、并发写入、完整 transition 和恢复均满足 Spec。 +- [ ] owning Spec/Note 保持正确状态、双语一致,checks 无新增失败。 +- [ ] 主控验收后更新计划状态;GitHub 发布/关闭仅按另行授权执行。 + +发生以下情况时暂停依赖该条件的步骤,返回具体证据与可继续的独立工作:实际 WASM 语义 +不同;storage 无法说明提交/发布边界;必须改动未授权私有系统;旧 writer 仍可破坏新契约; +现有单独 Tab 行为无法表达;持久化或完整 revision 测试失败。不要用 weaker invariant、 +blind compensation、无限 UI 等待或一次 toast 绕过这些条件。 + +本计划不要求现在提交或开新 PR。以后执行若需新分支,使用 `fix/session-lifecycle-commit`; +既有 #658 分支不做本地 rename/push。提交遵循 Conventional Commits,并添加实际运行模型的 +`Model:` trailer,不能猜测模型标识。 diff --git a/plans/README.md b/plans/README.md new file mode 100644 index 000000000..0f7c803c6 --- /dev/null +++ b/plans/README.md @@ -0,0 +1,24 @@ +# 实施计划 + +这里保存可交接给执行 Agent 的方案,设计依据在 owning Spec 和 Agent Note。 +当前只完成方案制定;没有切换生产实现,也没有改变 GitHub PR 或 Issue 状态。 + +| 顺序 | 计划 | 优先级 | 工作量 | 状态 | 启用条件 | +| ---- | ------------------------------------------------------------- | ------ | ------ | ---- | -------------------------------------------------- | +| 001 | [Session lifecycle 原子提交](001-session-lifecycle-commit.md) | P1 | L | TODO | 持久化、整体投影、并发决胜和旧 writer 准入均有证据 | + +执行 001 时按「依赖实验 → 操作模型、两种存储与切换门槛 → 生产者与消费者迁移 → 产品验收」推进。 +前三阶段可以拆成可独立评审的变更,但不能把某个阶段通过当作 #574 已完成。 +主控负责模型、提交边界和启用决策;执行 Agent 负责界限明确的实现与验证。 + +## 已排除的方向 + +- 调整 root/child 补偿顺序:不能证明当前字段仍属于本次操作。 +- `Promise.all` 或只包一层 `flock.txn`:不提供完整的 repository 投影与跨副本操作决胜。 +- `operationId === mine` 后 blind rollback:本地检查不能排除未同步的其他 writer。 +- 扫描 root/child 布尔值后自动修复:无法区分合法独立 Tab 操作与失败残留。 +- 原始 flags 与 operation 长期双写为两个权威:故障和旧客户端会重新制造分裂。 +- 修改 weaker invariant 后直接关闭 #574:与 owning Spec 的操作原子性不一致。 + +状态由负责最终验收的主控更新。执行 Agent 的测试结果必须注明代码版本、实际 adapter、 +已执行的命令与尚未验证的边界,不以测试数量代替证据。 diff --git a/plans/support/inspect-lifecycle-boundaries.cjs b/plans/support/inspect-lifecycle-boundaries.cjs new file mode 100644 index 000000000..1e803a622 --- /dev/null +++ b/plans/support/inspect-lifecycle-boundaries.cjs @@ -0,0 +1,133 @@ +// Characterize the pinned dependencies with synthetic in-memory replicas. +// This intentionally asserts existing limitations, not production fix acceptance. +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const { createRequire } = require('node:module'); +const { dirname, resolve } = require('node:path'); + +const manifest = resolve(process.argv[2] ?? 'packages/components/package.json'); +const dependencyRequire = createRequire(manifest); +const { Flock } = dependencyRequire('@loro-dev/flock-wasm'); +const { LoroRepo } = dependencyRequire('loro-repo'); +const versionOf = (name) => { + const entry = dependencyRequire.resolve(name); + return JSON.parse(readFileSync(resolve(dirname(entry), '..', 'package.json'), 'utf8')).version; +}; +const versions = { + repo: versionOf('loro-repo'), + wasm: versionOf('@loro-dev/flock-wasm'), +}; +assert.deepEqual(versions, { repo: '0.20.0', wasm: '0.4.3' }); + +const originalNow = Date.now; +Date.now = () => 1000; + +function inspectRollback() { + const local = new Flock('lifecycle-probe-local'); + const remote = new Flock('lifecycle-probe-remote'); + const root = ['m', 'session-root', 'isArchived']; + const child = ['m', 'session-child', 'isArchived']; + const status = ['m', 'session-child', 'status']; + local.txn(() => { + local.put(root, false, 1000); + local.put(child, false, 1000); + local.put(status, { type: 'running' }, 1000); + }); + remote.importJson(local.exportJson()); + remote.txn(() => { + remote.put(child, true, 2000); + remote.put(status, { type: 'requestPermission' }, 2000); + }); + local.importJson(remote.exportJson()); + const before = local.exportJson(); + const events = []; + local.subscribe((event) => events.push(event)); + assert.throws( + () => + local.txn(() => { + local.put(status, { type: 'idle' }, 3000); + local.put(root, true, 3000); + throw new Error('injected before commit'); + }), + /injected before commit/ + ); + assert.equal(local.get(root), false); + assert.equal(local.get(child), true); + assert.deepEqual(local.get(status), { type: 'requestPermission' }); + assert.deepEqual(local.exportJson(), before); + assert.equal(events.length, 0); + remote.importJson(local.exportJson()); + assert.deepEqual(remote.get(status), { type: 'requestPermission' }); + return { peerUpdatePreserved: true, exportUnchanged: true, emittedBatches: 0 }; +} + +async function inspectRepositoryPublication() { + const local = await LoroRepo.create({ metaDebounceCommitMs: 0 }); + let remote; + try { + remote = await LoroRepo.create({ metaDebounceCommitMs: 0 }); + await local.upsertDocMeta('session-child', { isArchived: false, parentSessionId: 'root' }); + await local.upsertDocMeta('session-root', { isArchived: false }); + remote.getMeta().importJson(local.getMeta().exportJson()); + // Pinned-version diagnostic only; product code must not depend on this private queue. + await remote.syncRunner.metaHydrationQueue; + await local.getDocMeta('session-root'); + await local.getDocMeta('session-child'); + const observations = []; + remote.watch((event) => { + if (event.kind !== 'doc-metadata') return; + observations.push( + Promise.all([remote.getDocMeta('session-child'), remote.getDocMeta('session-root')]).then( + ([child, root]) => ({ + eventDoc: event.docId, + child: child.meta.isArchived, + root: root.meta.isArchived, + }) + ) + ); + }); + local.getMeta().txn(() => { + local.getMeta().put(['m', 'session-child', 'isArchived'], true); + local.getMeta().put(['m', 'session-root', 'isArchived'], true); + }); + const raw = [ + local.getMeta().get(['m', 'session-child', 'isArchived']), + local.getMeta().get(['m', 'session-root', 'isArchived']), + ]; + const cached = [ + (await local.getDocMeta('session-child')).meta.isArchived, + (await local.getDocMeta('session-root')).meta.isArchived, + ]; + assert.deepEqual(raw, [true, true]); + assert.deepEqual(cached, [false, false]); + remote.getMeta().importJson(local.getMeta().exportJson()); + await remote.syncRunner.metaHydrationQueue; + const snapshots = await Promise.all(observations); + assert(snapshots.some((snapshot) => snapshot.child !== snapshot.root)); + assert.equal((await remote.getDocMeta('session-root')).meta.isArchived, true); + return { localRaw: raw, localCached: cached, remoteWatchSnapshots: snapshots }; + } finally { + await Promise.all([local.destroy(), remote?.destroy()]); + } +} + +(async () => { + try { + console.log( + JSON.stringify( + { + versions, + rollback: inspectRollback(), + publication: await inspectRepositoryPublication(), + }, + null, + 2 + ) + ); + } finally { + Date.now = originalNow; + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/specs/session-relations.md b/specs/session-relations.md index 3eb3a3d27..9e2f748e2 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: @@ -46,15 +48,62 @@ 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 metadata writes have no cross-document transaction. Direct children must be -written before the root, making the root the final commit point. A failed write must -attempt to compensate every attempted target to its pre-action lifecycle state. Root -compensation precedes child compensation; if it fails, children remain archived and the -write and rollback errors are surfaced together, preserving the root-archived -implication. Terminal closure starts only after all metadata writes succeed; a metadata -failure therefore closes no target terminal. Once the first write starts, the action -remains pinned to the captured workspace runtime until the write set commits or -compensation finishes. +### 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 @@ -86,13 +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 reads the repository metadata index for every action, so an interactive root -does not depend on the client projection having discovered its direct children. The +The current archive implementation reads the repository metadata index for every +action, so an interactive root does not depend on the client projection having +discovered its direct children. The query must complete before the first archive write, and query failure aborts the action without mutation. Its result is complete for the repository snapshot observed by that query; it is not a transaction boundary and does not include children created after the snapshot. Restore still discovers direct children from the client metadata cache and -therefore retains the cold-start implementation gap. +therefore retains the cold-start implementation gap. Archive still uses independent +writes and snapshot compensation; this does not implement the atomic lifecycle and +concurrent-write guarantees above. Existing resource reconciliation does not repair a +partially applied metadata transition. + +The replacement direction is a durable operation record with a shared, atomically +published projection. Its conflict ordering, persistence boundary, and mixed-client +rollout must pass the [implementation plan](../plans/001-session-lifecycle-commit.md) +before this draft can be treated as implemented. The +[decision proposal](../.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md) +records the dependency evidence and alternatives. Children created after the discovery +snapshot, recursive containment, and atomic permanent deletion are outside this change. ## Evidence @@ -108,6 +169,7 @@ CLI direct-child selection and the nested-child rejection are in and [`session-navigation.test.ts`](../packages/components/tests/session-navigation.test.ts). -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 the full operation contract. 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..30a4263ea --- /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) 的范围。 + +当前归档实现会为每次操作读取仓库元数据索引,因此交互式根不依赖客户端投影是否发现了其直接子级。查询必须在首次归档写入前完成,查询失败会在不产生变更的情况下中止操作。其结果完整覆盖查询所观察到的仓库快照;它不是事务边界,也不包括快照之后创建的子级。恢复仍从客户端元数据缓存发现直接子级,因此保留冷启动实现缺口。归档仍使用独立写入和快照补偿;这并未实现上文的原子生命周期与并发写入保证。现有资源协调不会修复部分应用的元数据变更。 + +替换方向是持久化操作记录与共享的原子发布投影。其冲突排序、持久化边界和混合客户端发布必须通过[实现计划](../plans/001-session-lifecycle-commit.md),之后本 draft 才能视为已实现。[决策提案](../.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)。 + +包含关系目标规则由 [#569](https://github.com/LodyAI/Lody/pull/569) 实现。归档接受标准仍由 [#574](https://github.com/LodyAI/Lody/issues/574) 跟踪;仅有发现并不能建立完整操作契约。本 draft 仍待人工批准。 From ea6f0bf363a6b8e10a331792b2c811b3df904ae6 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:49:28 +0800 Subject: [PATCH 5/5] fix: make session lifecycle commits durable Model: gpt-5 --- ...session-archive-complete-metadata-query.md | 7 +- ...sion-archive-complete-metadata-query.zh.md | 7 +- .../2026-09-13-session-lifecycle-commit.md | 80 ++- .../2026-09-13-session-lifecycle-commit.zh.md | 26 +- apps/cli/src/commands/session.test.ts | 23 + apps/cli/src/commands/session.ts | 34 +- apps/cli/src/lib/README.md | 5 + apps/cli/src/lib/lody.ts | 1 + apps/cli/src/lib/loro/doc.ts | 47 ++ .../session-lifecycle-persistence.test.ts | 94 +++ .../lib/loro/session-lifecycle-persistence.ts | 178 ++++++ apps/cli/src/lib/message-handler.ts | 164 ++++- .../tests/local-platform-zero-cloud.test.ts | 2 + .../loro-document-manager-create.test.ts | 16 + .../message-handler-terminal-cleanup.test.ts | 218 ++++++- e2e/COVERAGE.md | 2 +- e2e/journeys/registry.json | 7 +- e2e/src/features/session-management.feature | 4 +- e2e/src/steps/session-management.steps.ts | 11 +- .../session-relation-lifecycle-fixture.ts | 87 +++ .../pages/session-relation-lifecycle-page.ts | 49 ++ packages/components/src/atoms/doc-meta.ts | 21 + packages/components/src/atoms/runtime.ts | 3 + packages/components/src/hooks/README.md | 11 + .../src/hooks/use-session-actions.ts | 111 ++-- packages/components/src/lib/doc-meta-batch.ts | 25 +- .../src/lib/session-lifecycle-persistence.ts | 197 ++++++ .../src/providers/create-workspace-runtime.ts | 55 ++ .../src/providers/workspace-writer-impl.ts | 9 + .../src/providers/workspace-writer.ts | 7 + .../SessionLifecyclePersistence.stories.tsx | 25 + .../tests/doc-meta-subscription.test.ts | 86 +++ .../e2e/session-lifecycle-persistence.spec.ts | 123 ++++ .../session-lifecycle-persistence.test.ts | 22 + .../tests/use-session-actions.test.ts | 177 +++++- packages/shared/src/index.ts | 2 + .../src/session-lifecycle-repository.ts | 588 ++++++++++++++++++ packages/shared/src/session-lifecycle.ts | 212 +++++++ .../session-lifecycle-repository.test.ts | 398 ++++++++++++ .../shared/tests/session-lifecycle.test.ts | 119 ++++ plans/001-session-lifecycle-commit.md | 342 ---------- plans/README.md | 24 - .../support/inspect-lifecycle-boundaries.cjs | 133 ---- specs/session-relations.md | 48 +- specs/session-relations.zh.md | 8 +- 45 files changed, 3156 insertions(+), 652 deletions(-) create mode 100644 apps/cli/src/lib/loro/session-lifecycle-persistence.test.ts create mode 100644 apps/cli/src/lib/loro/session-lifecycle-persistence.ts create mode 100644 packages/components/src/lib/session-lifecycle-persistence.ts create mode 100644 packages/components/src/stories/SessionLifecyclePersistence.stories.tsx create mode 100644 packages/components/tests/e2e/session-lifecycle-persistence.spec.ts create mode 100644 packages/components/tests/session-lifecycle-persistence.test.ts create mode 100644 packages/shared/src/session-lifecycle-repository.ts create mode 100644 packages/shared/src/session-lifecycle.ts create mode 100644 packages/shared/tests/session-lifecycle-repository.test.ts create mode 100644 packages/shared/tests/session-lifecycle.test.ts delete mode 100644 plans/001-session-lifecycle-commit.md delete mode 100644 plans/README.md delete mode 100644 plans/support/inspect-lifecycle-boundaries.cjs 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 index 3daee56fa..411641e35 100644 --- 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 @@ -53,6 +53,9 @@ opened Sessions. It verifies the target set and terminal set, child-write and fi 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 implements [#574](https://github.com/LodyAI/Lody/issues/574) and complements the -containment decision recorded in +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 index d646f0177..6208e3be1 100644 --- 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 @@ -42,6 +42,7 @@ runtime。终端关闭是提交后的尽力清理:元数据失败不会关闭 元数据失败时不关闭终端,以及切换工作区的两个边界:首笔写入前中止,首笔写入后继续在捕获的 runtime 完成提交。 -本次改动实现 [#574](https://github.com/LodyAI/Lody/issues/574),并补充 -[让被打开的 Session 不受开启者状态级联影响](2026-09-10-session-containment-lifecycle.zh.md) -所记录的包含关系决策。 +这是 [#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 index 3b59e3ef9..85fdacc86 100644 --- a/.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md +++ b/.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md @@ -4,21 +4,20 @@ Status: proposed Translation: current Contract: [Session relations](../../../../specs/session-relations.md) -Execution: [Implementation plan](../../../../plans/001-session-lifecycle-commit.md) [中文](2026-09-13-session-lifecycle-commit.zh.md) ## Abstract -Archive currently 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 proposed replacement records each archive or restore as one +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, but also expose non-atomic repository observation and -per-key conflict resolution. Production persistence and mixed-client migration remain -unverified, so this is a proposal rather than a completed fix for #574. +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 @@ -51,13 +50,31 @@ database transaction framework. A compatible client still authors locally agains 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 [reproducible baseline probe](../../../../plans/support/inspect-lifecycle-boundaries.cjs) -checks rollback and repository publication against those pinned versions. +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 | | ------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | @@ -97,13 +114,12 @@ infer whether `root active / child archived` is an intentional Tab action or fai compensation from those flags alone. Existing worktree GC reconciles disk resources, not lifecycle metadata, and remains responsible only for root-owned worktrees. -The chosen operation ordering, storage admission before publication, migration -baseline, and old-writer policy are gates before production path replacement in the -plan. Both IndexedDB and SQLite admission/recovery prototypes must pass before that -switch; proving one adapter is insufficient. 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; -do not claim their migration or silently enable a weaker dual-write mode. +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 @@ -116,14 +132,24 @@ records its historical implementation; it is not approval of this replacement. ## Verification and rollout -No production implementation or two-product-client persistence/restart test has been -completed for this proposal. The execution plan requires deterministic regression, -two-repository observation, concurrent operations, durable recovery, and legacy-client -fixtures before activation. Worktree cleanup and terminal disposal are evaluated -against effective committed state and do not make lifecycle commits reversible. - -[#658](https://github.com/LodyAI/Lody/pull/658) is the affected implementation, not a -completed replacement PR. [#574](https://github.com/LodyAI/Lody/issues/574) remains open -until the operation contract and migration gates have evidence. Local documentation -checks currently also report pre-existing links into uninitialized ACP submodules; -those findings are separate from implementation verification. +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 index 66a303f0a..8f81dbe91 100644 --- 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 @@ -4,13 +4,12 @@ Status: proposed Translation: current 契约:[Session 关系](../../../../specs/session-relations.md) -执行:[实现计划](../../../../plans/001-session-lifecycle-commit.md) [English](2026-09-13-session-lifecycle-commit.md) ## 摘要 -当前归档通过恢复先前读取的快照来补偿失败的元数据写入,这可能覆盖合法的并发写入,也可能自身只留下部分目标的变更。提议的替代方案将每次归档或恢复记录为一个不可变操作,并通过共享的仓库投影推导有效的 Session 生命周期状态。操作是冲突解决、持久化和发布的单位;资源清理跟随所得状态。真实依赖探针确认本地 WASM 的回滚行为,但也暴露出非原子仓库观察和按键冲突解决。生产持久化和混合客户端迁移仍未验证,因此这是针对 #574 的提案,而不是已完成的修复。 +遗留产品拓扑通过恢复先前读取的快照来补偿失败的元数据写入,这可能覆盖合法的并发写入,也可能自身只留下部分目标的变更。本地 OSS 拓扑现在会把每次归档或恢复记录为一个不可变操作,并通过共享的仓库投影推导有效的 Session 生命周期状态。操作是冲突解决、持久化和发布的单位;资源清理跟随所得状态。真实依赖探针确认本地 WASM 的回滚行为,真实 IndexedDB、SQLite 与 LoroRepo 测试覆盖了替代边界。产品混合客户端仍没有准入机制,因此更广泛的发布仍处于提案状态,#574 对该拓扑也尚未完成。 ## 决策与范围 @@ -24,11 +23,24 @@ Translation: current 这是生命周期专用协议,不是通用 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 数据。 -[可复现的基线探针](../../../../plans/support/inspect-lifecycle-boundaries.cjs)针对这些固定版本检查回滚与仓库发布行为。 +基线探针针对这些固定版本检查了回滚与仓库发布行为;持久性契约现在由下文列出的 shared、browser 与 CLI owning tests 维护。 | 边界 | 观察到的行为 | 后果 | | ------------------ | ----------------------------------------------------------------------------- | --------------------------------------------------- | @@ -52,7 +64,7 @@ WASM 包随附的注释警告数据不会回滚,这与测试过的二进制相 后台协调器可以从持久化操作重建派生状态。它无法仅从标志判断 `root active / child archived` 是有意的 Tab 操作还是失败的补偿。现有 worktree GC 协调的是磁盘资源,而不是生命周期元数据,并且仍只负责根拥有的 worktree。 -选定的操作排序、发布前的存储准入、迁移基线和旧写入者策略,是计划中替换生产路径前的门槛。切换前 IndexedDB 和 SQLite 的准入/恢复原型都必须通过;只证明一个 adapter 不够。daemon capability 无法隔离独立写入旧 renderer 的行为。公共仓库不包含所有产品客户端或工作区级的写入者准入机制;不得声称它们已迁移,也不得悄然启用较弱的双写模式。 +本地 OSS renderer 与 daemon 属于同一个协调发布物,只在 runtime 为纯本地拓扑时启用新权威。cloud 与 dual runtime 保留遗留路径,因为 daemon capability 无法隔离独立写入旧 renderer 的行为。公共仓库不包含所有产品客户端或工作区级的写入者准入机制;产品启用需要外部证据,也不得用较弱的双写模式替代。 ## 与早期决策的关系 @@ -60,6 +72,8 @@ WASM 包随附的注释警告数据不会回滚,这与测试过的二进制相 ## 验证与发布 -本提案尚未完成生产实现,也没有完成两个产品客户端的持久化/重启测试。执行计划要求在启用前完成确定性回归、双仓库观察、并发操作、持久恢复以及遗留客户端 fixture。Worktree 清理和终端处置依据有效提交状态评估,不会使生命周期提交可逆。 +本地实现已有确定性的 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) 是受影响的实现,而不是已完成的替代 PR。在操作契约和迁移门槛获得证据前,[#574](https://github.com/LodyAI/Lody/issues/574) 仍保持开放。本地文档检查目前也报告了指向未初始化 ACP 子模块的预先存在链接;这些发现与实现验证无关。 +[#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 155b1fb50..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'; @@ -36,6 +36,8 @@ import { normalizeSessionTurnInputConfig, readMachineFlockRowsFromFlock, sanitizeMessageTextSpans, + SessionLifecycleAdmissionUncertainError, + type SessionLifecycleOperationDraft, } from '@lody/shared'; import { useAtomValue, useSetAtom, useStore } from 'jotai'; import { usePostHog } from '@posthog/react'; @@ -260,7 +262,7 @@ async function listSessionMetadataSnapshot(runtime: WorkspaceRuntime): Promise session.id !== rootSessionId), root]; - const attemptedTargets: SessionMeta[] = []; + 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. - attemptedTargets.push(session); + touchedSessions.push(session); await runtime.writer.upsertDocMeta(getSessionRoomId(session.id), { isArchived: true, status: SessionStatusFactory.idle(), } as Partial); } } catch (archiveError) { - const rollbackErrors: unknown[] = []; - const attemptedRoot = attemptedTargets.find((session) => session.id === rootSessionId); + 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 @@ -300,12 +302,12 @@ async function writeArchiveStateFailureSafe( status: attemptedRoot.status, } as Partial); } catch (rollbackError) { - rollbackErrors.push(rollbackError); + compensationFailures.push(rollbackError); } } - if (!attemptedRoot || rollbackErrors.length === 0) { - for (const session of [...attemptedTargets].reverse()) { + if (!attemptedRoot || compensationFailures.length === 0) { + for (const session of [...touchedSessions].reverse()) { if (session.id === rootSessionId) continue; try { await runtime.writer.upsertDocMeta(getSessionRoomId(session.id), { @@ -313,17 +315,17 @@ async function writeArchiveStateFailureSafe( status: session.status, } as Partial); } catch (rollbackError) { - rollbackErrors.push(rollbackError); + compensationFailures.push(rollbackError); } } } - if (rollbackErrors.length > 0) { + if (compensationFailures.length > 0) { const failure = new Error( - `Archive failed and ${rollbackErrors.length} lifecycle rollback(s) also failed`, + `Archive failed and ${compensationFailures.length} lifecycle rollback(s) also failed`, { cause: archiveError } ); - Object.assign(failure, { rollbackErrors }); + Object.assign(failure, { compensationFailures }); throw failure; } throw archiveError; @@ -577,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( @@ -604,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; @@ -1252,31 +1279,31 @@ export function useSessionActions(): SessionActions { throw new Error('Runtime not ready'); } - const sessionRoomId = getSessionRoomId(sessionId); 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); - // The repository snapshot is preferred, but it can lag a Session - // the UI already renders. The archive write below is an idempotent patch, - // so rendered root metadata is enough to proceed. Descendant discovery - // still comes exclusively from the queried snapshot above. - const sessionMeta = - repoMeta ?? (store.get(sessionMetaCacheAtom)[sessionRoomId] as SessionMeta | undefined); - if (!sessionMeta) { + 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, sessionMetadata); - // The first write is the commit boundary. From here the captured runtime - // must finish the old-workspace write set or compensate it; switching the - // active workspace cannot redirect or cancel an in-flight commit. - await writeArchiveStateFailureSafe(runtime, sessionId, archiveTargets); + 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 @@ -1298,7 +1325,7 @@ export function useSessionActions(): SessionActions { targetSessionIds: archiveTargets.map((session) => session.id), }); }, - [runtime, store] + [commitSessionLifecycle, runtime, store] ); const restoreSession = useCallback( @@ -1316,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 54d33d8f1..f53c6d72b 100644 --- a/packages/components/tests/use-session-actions.test.ts +++ b/packages/components/tests/use-session-actions.test.ts @@ -10,6 +10,7 @@ import { getMachineRoomId, getSessionRoomId, machineFlockKeys, + SessionLifecycleAdmissionUncertainError, type MachineId, type SessionId, type SessionMeta, @@ -123,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 = @@ -152,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, @@ -184,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), @@ -1150,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, @@ -1174,12 +1188,10 @@ describe('useSessionActions', () => { 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( @@ -1226,6 +1238,155 @@ describe('useSessionActions', () => { } }); + 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); + + 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: 'running' }, + thirdPartyField: 'kept', + }); + 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', @@ -1353,7 +1514,7 @@ describe('useSessionActions', () => { expect(failure).toMatchObject({ message: 'Archive failed and 1 lifecycle rollback(s) also failed', cause: { message: 'root archive acknowledgement failed' }, - rollbackErrors: [{ message: 'root rollback failed' }], + compensationFailures: [{ message: 'root rollback failed' }], }); expect(metaRepo.getSession(rootSession.id)).toMatchObject({ isArchived: true }); expect(metaRepo.getSession(tabSession.id)).toMatchObject({ isArchived: true }); 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/plans/001-session-lifecycle-commit.md b/plans/001-session-lifecycle-commit.md deleted file mode 100644 index 93c46a7f1..000000000 --- a/plans/001-session-lifecycle-commit.md +++ /dev/null @@ -1,342 +0,0 @@ -# Plan 001:以原子操作替代 Session archive 补偿 - -> 执行 Agent:先完整阅读本计划,再按阶段实施。每阶段返回 diff、实际命令和可复现结果; -> 主控负责架构取舍与最终验收。本计划没有授权 push、修改 PR、关闭 Issue 或启用不兼容协议。 -> 先执行下面的漂移检查;发生漂移时对照现有代码与摘录修订计划,不能机械套用行号。 - -## 状态与目标 - -- Priority: P1;Effort: L;Risk: HIGH;Category: correctness / architecture。 -- Planned at: `54623883be77bd17f9dab18ef5a60cc2a9b156ef`,2026-09-13。 -- Depends on: 无其他计划;生产启用依赖本计划中的存储与兼容性门槛。 -- 工作对象:[Issue #574](https://github.com/LodyAI/Lody/issues/574) 与 - [PR #658](https://github.com/LodyAI/Lody/pull/658)。当前分支为 `fix/archive-hydration-bounded-wait`。 -- Intent:[Session relations](../specs/session-relations.md)。 -- Rationale:[原子 lifecycle 提交提案](../.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md)。 - -一次 root archive 或 restore 必须以同一个操作覆盖它的冻结目标集。提交前失败不能留下 -本次变更;提交后的确认失败不能恢复旧快照。兼容副本可以在不同时间收到操作,但同一 -有效状态快照不能暴露该操作只应用了一部分。单独归档 Tab 仍合法。 - -## 当前实现与可保留部分 - -| Owner | 当前职责与问题 | -| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `packages/components/src/hooks/use-session-actions.ts` | repository discovery 已修正冷启动漏 child;archive 仍逐条写入和补偿,restore 仍查 UI cache。 | -| `packages/components/src/providers/workspace-writer{,-impl}.ts` | 所有 renderer 本地 author;`upsertDocMeta` 只转发单次 repo 调用。 | -| `packages/components/src/atoms/doc-meta.ts` | 本地 patch 逐 doc 立即发布;远端 patch 有大小限制的分批发布。 | -| `apps/cli/src/commands/session.ts` | CLI archive/restore 对 root 和 children 并行调用单 doc writer;MCP archive 复用该命令。 | -| `apps/cli/src/lib/message-handler.ts` | 观察单 Session archive 后释放 runtime;另有 local-project removal 的逐条 archive writer。 | -| `packages/shared/src/schema.ts` | `SessionMeta.isArchived` 是可选布尔值,尚无 lifecycle operation 契约。 | -| `patches/loro-repo.patch`、`pnpm-lock.yaml` | 固定依赖与补丁;现有 patch 只修复 metadata live monitor 启动。 | - -漂移检查: - -```sh -git diff --stat 54623883be77bd17f9dab18ef5a60cc2a9b156ef..HEAD -- packages/components packages/shared apps/cli patches/loro-repo.patch pnpm-lock.yaml specs/session-relations.md -git status --short -``` - -关键现状摘录,来自 `use-session-actions.ts:280` 与 `:311`: - -```ts -attemptedTargets.push(session); -await runtime.writer.upsertDocMeta(getSessionRoomId(session.id), { - isArchived: true, - status: SessionStatusFactory.idle(), -}); -// 失败后的另一次 authored write: -await runtime.writer.upsertDocMeta(getSessionRoomId(session.id), { - isArchived: session.isArchived, - status: session.status, -}); -``` - -第二段的值来自先前 snapshot,不代表当前值由本操作拥有。失败期间另一个 writer -修改状态后,这段补偿会产生新的 CRDT 写入覆盖它。 - -保留 repository discovery 的思路、仅 direct `parentSessionId` 的目标规则、workspace -切换边界、独立 `openedBy*` Session 不受影响、提交后 best-effort terminal cleanup。 -删除 snapshot compensation、children/root 排序作为正确性基础、`attemptedTargets`、 -`rollbackErrors`,以及 rendered cache 作为生命周期提交依据。 - -## 已知依赖能力,不得扩大解释 - -`loro-repo@0.20.0` 的 metadata 使用 `@loro-dev/flock-wasm@0.4.3`,字段位于同一个 -meta Flock 的 `m/docId/field` key。真实内存实验已观察到 WASM 同步 transaction 抛错时 -撤销数据、事件和导出变更,但 `@loro-dev/flock@4.4.4` 的同名 API 不撤销数据。 -WASM 文档与二进制还存在该语义差异,必须用行为测试锁住具体版本。 - -`getMeta().txn` 不能直接替代应用 writer:原始值改变后,LoroRepo 的已加载 cache -仍可保持旧值;远端整批 import 又会逐 doc reconcile,向 watcher 暴露中间状态。 -字段各自拥有 CRDT 时钟,多 key transaction 没有整体冲突决胜保证。 -`upsertDocMeta` 不等待落盘;`persistMetaNow` 是另一个边界。 - -可复现的基线探针:[inspect-lifecycle-boundaries.cjs](support/inspect-lifecycle-boundaries.cjs)。 -在已安装依赖的 clone 中运行: - -```sh -node plans/support/inspect-lifecycle-boundaries.cjs packages/components/package.json -``` - -它应 exit 0,并报告 `peerUpdatePreserved: true`、`exportUnchanged: true`、本地 raw/cache -分别为 `[true,true]` / `[false,false]`,远端 watcher 曾看到 child/root 不一致。 -这些断言描述旧依赖的局限,不是修复验收;升级后行为变化必须重新评估。 -可传另一个显式 package.json 路径定位已安装的同版本依赖,不安装或修改该依赖目录。 - -Frontend runtime 在 `create-workspace-runtime.ts:416` 显式设置 -`metaDebounceCommitMs: 0`。默认 debounce 与 `txn` 互斥不是这个 runtime 的直接阻碍。 -事务内禁止 async、Promise 回调和 import;import 可能先提交再报错。 - -## 选择的操作模型 - -以一条完整、不可变的 JSON record 表达一次操作,存储 key 由 repository adapter 管理。 -以下是待原型验证的 v1 数据形状,字段名称可以在同一阶段调整,语义不可省略: - -```ts -type SessionLifecycleOperation = { - version: 1; - operationId: string; - subjectId: SessionId; - targetIds: readonly SessionId[]; - state: 'archived' | 'active'; - order: { counter: string; actorId: string }; -}; -``` - -- `targetIds` 来自同一次 repository discovery,去重后冻结,必须包括 subject。 - root 操作包含所发现 direct children;Tab 单独操作只有该 Tab。不包含 `openedBy*`。 -- 使用单 key、完整 JSON value 的写入。禁止把 record 用自动展开对象的 API 拆成多个 key, - 也禁止靠多 key 的 `pending/committed` 标记拼出未经证明的原子性。 -- 操作按不可变 id 保留,不能让一个可变 root record 的整对象替换丢掉尚未同步的操作。 - 重试复用相同 id、排序和 payload;同 id 不同内容属于协议冲突,不能任选一份继续。 -- 提议采用 Lamport 顺序:新操作的 counter 大于当前已观察到和本地已预留的 counter; - 序列化为规范非负十进制字符串,用数值比较,禁止按字符串排序或依赖墙钟。 - 同 counter 时按 actorId、operationId 的稳定字节序决胜。重试绝不提升排序。 - 同一 workspace/store 的 counter 分配与 record 持久化接纳必须串行化;共享存储的多个 - tab/process 需要存储事务或等价协调,不能仅靠进程内变量。启动先恢复已发布与已接纳 - 未发布记录的 high-water mark,再开放新命令;不能只扫描已发布的 Flock 状态。 -- resolver 对每个目标选取覆盖它的最高顺序操作;一个 root 操作的排序对所有目标相同。 - 固定目标集上的两个 root 操作整体决胜,较新的 singleton Tab 操作只覆盖该 Tab。 - 反过来,较新的 root 操作覆盖它所包含的旧 singleton。两个 root 操作的冻结集合不同 - 时,共有目标由较新操作决定,不在较新集合的目标保留最后一个覆盖它的操作结果。 - 这是明确的操作顺序,不承诺对未观察到的远端操作具备真实时间线性一致性。 -- 从完整 record 计算所有受影响目标,再一次发布有效 metadata revision。`isArchived` - 是该投影的结果,不能同时保留另一套独立 author 的权威 flags。 -- lifecycle 操作不写回或伪造 runtime `status`;实际终止后由既有 runtime owner 发布 idle。 - 初次创建 Session 的未归档初始化是 baseline,不等同于 archive/restore 命令。 -- deletion/existence 优先:operation 不创建、不复活已删除或未知的 Session。冻结目标里 - 尚未到达的 metadata 可以保持未知,之后 hydration 必须使用同一个 resolver。 -- v1 不清理 operation 历史。建立按目标索引与增量投影;不在每次组件 render 全量重放。 - 记录增长成本与后续 checkpoint 条件,不能在没有离线副本保留契约时按时间删除记录。 - -这个模型需要原型与契约评审后才能冻结 wire format。它限定于 archive/restore,不能扩成 -通用 Operation 调度器、worker supervision 或新的云端服务。 - -## 提交、持久化与恢复 - -```text -discovery / validation - -> durable admission of one immutable record - -> publish effective revision and replicate - -> reconcile current resource state -``` - -repository adapter 必须说明谁持久化 record、谁允许它进入实时投影和同步流、失败由谁接管。 -优先复用具备提交前隔离与落盘边界的 repository 能力;当前 `put; await persistMetaNow()` -会先发事件,不能直接当成已证明的 durable admission。 - -若依赖不能延迟发布,需要受测试保护的 dependency 扩展,或在既有本地存储中原子写入 -该 record 的专用 admission journal,再按同 id 发布。journal 只保存生命周期提交依据, -不是回滚快照或另一套任务调度队列。必须先完成 IndexedDB/SQLite 适配原型与恢复测试, -才确定生产存储布局;不要让执行 Agent 猜测底层 transaction 的保证。 - -| 失败位置 | 必需结果 | -| ------------------------------------ | ------------------------------------------------------------------ | -| 验证或持久化接纳之前,且确认没有接纳 | 没有本次有效状态、同步更新、terminal effect;可报告 rejected。 | -| 存储调用结果不确定 | 返回可查询的同一 operationId;不能谎称零写入,也不能 author 补偿。 | -| record 已持久化,发布或回复失败 | 返回/恢复同一提交;启动重放、重连、重复请求都不新建操作。 | -| 同步或资源清理失败 | 保留 lifecycle 事实,由同步/资源 owner 重试,不恢复旧状态。 | -| 本操作已被较新操作覆盖 | 保留历史身份;重试不提升排序,不重放过期 teardown。 | - -进程重启只能恢复已经持久化的依据。存储不可用期间不能承诺尚未落盘的操作跨重启存活。 -UI 导航、terminal cleanup 和 CLI 成功响应以明确的 durable receipt 为依据;同步确认另行 -呈现。发布与重放采用至少一次交付:同 operation/revision 可重复通知,不能承诺跨崩溃 -exactly-once 事件。订阅携带稳定 operation 身份与可识别的 lifecycle revision;重复交付 -不能成为新逻辑操作、提升排序或重复破坏资源。分别测试落盘后发布前、发布后记录完成前 -崩溃;恢复时按完整已知操作集重算当前 revision,不强制逐条通知已经过期的中间态。 - -watcher 和资源执行点重读当前有效状态,并与同一 Session 的 start/resume 协调:排队的 -旧 archive 不得作用于 restore 后的新 runtime generation。跨 await 的 teardown 必须 -绑定捕获的资源实例/代次或使用等价串行化约束;只在开始时检查一次布尔值不足以证明安全。 - -## 模块边界与修改范围 - -| 层 | 计划修改范围 | 责任 | -| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| 共享契约 | `packages/shared/src/session-lifecycle.ts`、`packages/shared/tests/session-lifecycle.test.ts`(新增),必要的 exports/schema | parser、排序、纯 resolver、结果类型;平台中立。 | -| repository 接缝 | `packages/shared/src/session-lifecycle-repository.ts`、`packages/shared/tests/session-lifecycle-repository.test.ts`(新增);必要的 `patches/loro-repo.patch`、catalog/manifest/lock | 平台中立的接纳、恢复和完整 revision 发布 owner;注入实际存储 port。 | -| IndexedDB 持久化 | `packages/components/src/lib/session-lifecycle-persistence.ts`、`packages/components/tests/session-lifecycle-persistence.test.ts`、`packages/components/tests/e2e/session-lifecycle-persistence.spec.ts`(新增) | 使用实际 browser 存储验证 durable admission 和跨 reload 恢复;不以 fake IndexedDB 作为最终证据。 | -| SQLite 持久化 | `apps/cli/src/lib/loro/session-lifecycle-persistence.ts`、相邻 `session-lifecycle-persistence.test.ts`(新增),必要时扩展 `sqlite-repo-store.ts` | 沿用隔离 workspace 存储命名空间,验证真实 SQLite 接纳、关闭重开与 replay。 | -| renderer | `workspace-writer.ts`、`workspace-writer-impl.ts`、`create-workspace-runtime.ts`、`atoms/runtime.ts`、`atoms/doc-meta.ts`、`use-session-actions.ts` | 注入统一 owner,替换 archive/restore,原子更新 cache;释放 workspace 时保留已接纳责任。 | -| CLI/runtime | `apps/cli/src/commands/session.ts`、`lib/loro/doc.ts`、`lib/message-handler.ts`、`session/session-dispatch-watcher.ts`、`session/session-execution-service.ts` | 命令与 local-project removal 走同一 writer;查询、dispatch、resume、GC 读有效状态。 | -| 其他消费者 | `providers/background-sync-coordinator.ts`、CLI list/show、MCP summaries、归档 UI 的必要读取接缝 | 接收统一投影,不在各消费者复制 resolver。 | -| 测试与文档 | 上述 owning suites、`specs/session-relations*`、本提案、受影响 README/AGENTS | 行为证据和正确的实施状态。 | - -遵守根和各 scoped AGENTS;涉及 protocol capability 时读 `packages/shared/AGENTS.md`。 -shared 不依赖私有包或 hosted API。每个客户端继续 author 自己的 repo,不恢复 daemon proxy。 -不要逐组件添加 fallback;通过明确的 repository metadata reader 契约让现有消费者获得 -统一结果,并在迁移清单中核实所有 raw reader。访问 raw fields 的必要场景必须显式命名。 - -不在范围内:永久删除的事务化、nested child、snapshot 后创建 child 的完整性保证、#529、 -worktree GC 策略重写、真实用户数据迁移实验、私有 Web/mobile 源码、运营准入配置。 -实现发现必须修改新的 owner 时,先更新此处具体范围与理由,再由主控判断。 - -## 执行阶段与验证 - -### 1. 固定依赖能力与失败证据 - -在 owning writer/cache suite 中建立真实 WASM 与 LoroRepo 的确定性 fixture,保留现有 cold-start -discovery 用例。使用可释放 Promise gate、注入时钟与 synthetic metadata,不用真实 sleep。 -先记录旧实现的两个反例:第三方更新后 pre-mutation reject 覆盖状态,以及 child 补偿失败。 -另测 raw txn/cache 不一致和远端逐 doc publication,避免修复只通过 fake repo。 - -运行下方组件命令。旧实现应在指定新 regression 上失败;已有行为测试仍通过。 -提交证据必须能区分 baseline 失败与测试环境缺依赖。随后实现应让这些 regression 转绿, -不能删除断言以获得通过。 - -### 2. 验证操作模型、两种存储与切换门槛 - -新增共享 parser/resolver 测试,覆盖验收矩阵中的排序、成员集、未知与删除目标。 -按上述明确路径分别做 IndexedDB 和 SQLite 最小持久化原型;两者都注入写前失败、已存后 -返回失败、发布前重启、发布后重放,不能只证明第一个 adapter。验证并发本地接纳的 counter -分配,以及已接纳未发布后重启、新操作的顺序。证明 reader 只看到完整 revision,并保留 -第三方无关 metadata 和 status。冻结 record keyspace、完整 JSON 编码、API/result、order -和本地持久化布局,写回 owning Note。 - -在接入生产之前确定可执行的 baseline/旧 writer 策略及启用机制。明确哪些拓扑可以协调 -切换,哪些仍被阻断;用遗留 writer 与离线重连 fixture 验证,而不是只写一个 feature flag。 -这个阶段的新路径保持未启用,现有生产路径暂留且仍标记为未修复;不得先删除旧路径, -之后才发现新协议无法启用。没有一条长期双权威的过渡实现。 - -运行 shared、两种 persistence 与组件命令,全部通过。浏览器用 owning Playwright suite 的 -Vite 模块加载方式运行真实 adapter;隔离数据库名,关闭/reload 后重建 owner,不模拟落盘。 -单 record 已足够时不另造通用多 doc transaction API。 -若依赖修改必要,需附源码来源、版本、生成方式和发布/patch 路径;禁止只改 node_modules。 -不能完成这个阶段的原子性、两种持久性与可执行切换证明时,不开始切换生产调用点。 - -### 3. 迁移同一权威的生产者与消费者 - -同时替换 renderer/CLI archive 与 restore;MCP archive 复用 CLI,无需新增远程 author。 -local-project removal 保留原有重试/资源责任,但每个生命周期操作走新 writer。 -初始未归档 baseline 保留,普通 status producer 不参与旧值恢复。 - -本地与远端投影都整批安装再通知。把 `getDocMeta`、list/scan、watch、UI atom、daemon -dispatch/resume/GC 的读取接缝逐一纳入一致性测试,不能只改变 sidebar 的显示。 -先在满足阶段 2 门槛的隔离拓扑中协调切换所有 writer/reader,再移除该拓扑的旧路径; -在授权的生产发布前完成阶段 4 验收。删除旧 helper 及依赖补偿顺序的断言,以提交结果、 -最终状态与可观察 revision 代替。若支持的拓扑尚不能一起迁移,不发布一个缺失可用路径的 -中间版本,也不能把保留旧路径的拓扑记为已修复。 - -运行组件、shared、CLI owning suites 与公共边界检查。`rg` 检查旧 helper 应零命中; -剩余 `isArchived` 写入逐处分类为初始化、兼容性入口或错误绕过,不能仅凭字符串数量验收。 - -### 4. 兼容性与产品验收后启用 - -复核阶段 2 已证明的切换机制:旧布尔值必须在明确的迁移边界成为 baseline。新格式启用后,不得继续接受无法表达同一 -操作的旧 flags 为并列权威。需要在实际 writer 准入边界处理旧 renderer、daemon 和离线重连, -或者有覆盖所有参与者的协调升级方案与证据。缺少该机制时保持新格式未启用,报告缺口。 - -`MachineMeta.protocolCapabilities` 只描述 daemon,不能证明所有 renderer 都支持新格式。 -当前公开源码没有足够机制证明产品所有客户端已迁移。本计划不授权修改 hosted 准入服务。 -本地 OSS 拓扑和产品多客户端拓扑分别验收,不能互相代替。 - -先执行 owning suites 和全量 checks,再用两个隔离 runtime、真实持久化与同步通道验收: -故障 gate 保证动作交错,重启复用隔离数据目录,结果用结构化状态而不是 toast 判定。 -覆盖既有 `LODY-SESSION-004` target/discovery 行为;新的故障用例放入 owning journey/fixture, -即 `e2e/src/features/session-management.feature`、 -`e2e/src/support/fixtures/session-relation-lifecycle-fixture.ts` 和 -`e2e/src/support/pages/session-relation-lifecycle-page.ts`。需要新 scenario 时同步 -`e2e/journeys/registry.json`,遵循 `e2e/AGENTS.md`。禁止使用真实 workspace 数据。 -该桌面验收证明 OSS 拓扑,不能替代私有 Web/mobile 的产品发布证据。 - -## 验收矩阵 - -| 场景 | 必须观察到的结果 | -| ----------------------------------------------- | ---------------------------------------------------------------------------- | -| snapshot 后另一 writer 改 child,本次提交前失败 | 另一方的 archived/status/无关字段保持;没有本次导出变更和事件。 | -| 第 N 个目标计算或验证失败 | 未持久化/发布任何部分 operation;不存在 child rollback 需求。 | -| durable write 已接受但返回失败 | 查询同 id 与重启确认同一提交;允许重复交付,不产生新操作、新排序或重复破坏。 | -| durable record 后、projection 前崩溃 | 重启完整重建所有目标;没有需要人工修复的 flags 分裂。 | -| projection 后、完成标记前崩溃 | 同操作 replay 幂等;所有订阅与资源效果能处理重复通知。 | -| 本地并发接纳、接纳未发布后重启再发命令 | 分配与持久化串行;新 counter 高于所有本地已接纳及已观察值。 | -| 已落后于新 restore 的 archive 重放 | restore 不被覆盖,旧 teardown 不执行。 | -| teardown await 期间 restore 并启动新 runtime | 旧任务不销毁新资源代次;检查发生在真实异步资源边界。 | -| 两个 root archive/restore 并发,固定成员集 | 合并顺序与重复交付不改变全体目标的同一胜者。 | -| root 操作与较新 singleton Tab 操作 | 只有 Tab 被后者覆盖;其他目标一致,root worktree 不受 Tab 单独操作支配。 | -| 旧 singleton 后收到较新 root 操作 | root 操作覆盖该 Tab;不存在 singleton 永久压过父操作的特殊规则。 | -| 相同 counter、不同 actor/id;重复和反向交付 | 所有副本遵循同一稳定比较顺序;最终状态与到达顺序无关。 | -| 两个 root 操作使用不同冻结集合 | 共有目标取较新操作;集合外目标保留最后覆盖值,不补写未选中的目标。 | -| 本地/远端 watch、get/list 与 UI revision | 每个已发布 revision 都来自完整操作集,不暴露逐目标安装中间态。 | -| cache 缺 root、只渲染了 root、workspace 切换 | 权威查询决定是否可提交;已接纳操作不被新 workspace 接管。 | -| 旧 writer、离线回归、未知 schema version | 按明确兼容策略拒绝/隔离或迁移;不能静默双写降级。 | -| 同步中断、runtime/terminal 清理失败 | durable operation 保持,恢复后收敛;无 metadata compensation。 | - -## 命令与环境 - -Node 22+,pnpm 使用根 `package.json` 固定版本。当前 nested worktree 没有 node_modules, -组件测试曾因 `vitest: command not found` 未执行。不要在 nested checkout 安装;需要完整 -workspace 验证时使用独立 clone 并按仓库要求初始化授权的 submodules、安装固定依赖。 -借用恰好匹配的依赖只能用于说明其版本的 isolated probes,不能冒充全量 typecheck。 - -| 用途 | 命令 | 成功结果 | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| 开始文档检查 | `pnpm run docs status` | 记录 baseline;当前有 20 个未初始化 submodule 链接错误。 | -| 组件行为 | `NODE_ENV=test pnpm --dir packages/components test tests/use-session-actions.test.ts tests/workspace-writer.test.ts tests/doc-meta-subscription.test.ts tests/doc-meta-batch.test.ts` | 修复完成后所有用例通过。 | -| 共享模型与 owner | `pnpm --dir packages/shared test tests/session-lifecycle.test.ts tests/session-lifecycle-repository.test.ts` | 新增模型与恢复行为全部通过。 | -| IndexedDB 接缝 | `NODE_ENV=test pnpm --dir packages/components test tests/session-lifecycle-persistence.test.ts` | 注入故障与 API 契约通过,不替代真实浏览器存储。 | -| 浏览器实际持久化 | `pnpm --dir packages/components test:e2e tests/e2e/session-lifecycle-persistence.spec.ts` | 真实 IndexedDB 的失败、重复交付和 reload 恢复通过。 | -| SQLite 实际持久化 | `pnpm --dir apps/cli test src/lib/loro/session-lifecycle-persistence.test.ts src/lib/loro/sqlite-repo-store.test.ts` | 隔离 SQLite 的失败与关闭重开恢复通过。 | -| CLI 资源边界 | `pnpm --dir apps/cli test tests/message-handler-terminal-cleanup.test.ts tests/worktree-gc.test.ts` | 原有资源语义与新恢复行为通过。 | -| CLI 命令 | `pnpm --dir apps/cli test src/commands/session.test.ts` | 既有 archive/restore 与新 writer 结果契约通过。 | -| 实际 repo 同步边界 | `pnpm --dir apps/cli test tests/loro-native-multi-transport.integration.test.ts tests/loro-doc-unload-data-plane-integration.test.ts` | 双副本 owning suite 与新增故障、恢复场景通过。 | -| 类型 | `pnpm --dir packages/shared typecheck`、`pnpm --dir packages/components typecheck`、`pnpm --dir apps/cli typecheck` | 正确依赖环境中 exit 0。 | -| 公共边界 | `pnpm check:public-boundary` | exit 0;无 private/cloud/local 边界变化。 | -| E2E 定义 | `pnpm e2e:check` | journey registry、scenario 与 fixture 一致。 | -| 桌面产品场景 | `pnpm e2e:build` 后 `pnpm --dir e2e exec cucumber-js --config cucumber.mjs --tags '@LODY-SESSION-004'` | 隔离的真实桌面/CLI 运行时完成目标、故障与恢复验收。 | -| 桌面 smoke | `pnpm e2e:smoke` | 构建后既有 P0 场景通过。 | -| 提交前 | `pnpm check`、`pnpm format`、`git diff --check` | 完整检查通过;review formatter 实际 diff,保留无关用户修改。 | -| 完成文档检查 | `pnpm run docs check` | 不增加 baseline 错误;可用 submodule 环境下应 exit 0。 | - -新建的测试路径必须在所属阶段实现后才能运行。最终旧 helper 检查应无命中(`rg` exit 1): - -```sh -rg -n 'writeArchiveStateFailureSafe|attemptedTargets|rollbackErrors' packages/components/src/hooks/use-session-actions.ts -``` - -测试风格参考 `workspace-writer.test.ts` 的 Promise gate 与状态断言,以及 -`doc-meta-subscription.test.ts` 的真实 LoroRepo 双副本 fixture。沿用 fixture 思路,替换其中 -依赖真实 timer 的等待,不复制浅层 mock-call-only 断言。 -`sqlite-repo-store.test.ts` 已使用真实临时 SQLite,但尚无故障注入证明; -`loro-doc-unload-data-plane-integration.test.ts` 有真实 repository/storage 与可控传输边界。 -浏览器 owning suite 的 Vite 加载可参考 `tests/e2e/terminal-theme.spec.ts`,使用实际存储 adapter, -不能以一次模拟的 unavailable error 代替持久化验证。浏览器 adapter 测试不冒充桌面产品 E2E。 - -## 完成条件与停止条件 - -- [ ] acceptance matrix 均有 owning test 或实际 runtime 证据,结果注明版本与存储 adapter。 -- [ ] 提交前失败零影响;不确定结果有稳定身份;重试和重启不提升旧操作优先级。 -- [ ] 生产者与消费者使用同一权威,原始 metadata 与有效投影的界限可审计。 -- [ ] 新格式的全体 writer 兼容性有证据;没有仅凭 daemon capability 推断 renderer 兼容。 -- [ ] #574 的 target/discovery、并发写入、完整 transition 和恢复均满足 Spec。 -- [ ] owning Spec/Note 保持正确状态、双语一致,checks 无新增失败。 -- [ ] 主控验收后更新计划状态;GitHub 发布/关闭仅按另行授权执行。 - -发生以下情况时暂停依赖该条件的步骤,返回具体证据与可继续的独立工作:实际 WASM 语义 -不同;storage 无法说明提交/发布边界;必须改动未授权私有系统;旧 writer 仍可破坏新契约; -现有单独 Tab 行为无法表达;持久化或完整 revision 测试失败。不要用 weaker invariant、 -blind compensation、无限 UI 等待或一次 toast 绕过这些条件。 - -本计划不要求现在提交或开新 PR。以后执行若需新分支,使用 `fix/session-lifecycle-commit`; -既有 #658 分支不做本地 rename/push。提交遵循 Conventional Commits,并添加实际运行模型的 -`Model:` trailer,不能猜测模型标识。 diff --git a/plans/README.md b/plans/README.md deleted file mode 100644 index 0f7c803c6..000000000 --- a/plans/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# 实施计划 - -这里保存可交接给执行 Agent 的方案,设计依据在 owning Spec 和 Agent Note。 -当前只完成方案制定;没有切换生产实现,也没有改变 GitHub PR 或 Issue 状态。 - -| 顺序 | 计划 | 优先级 | 工作量 | 状态 | 启用条件 | -| ---- | ------------------------------------------------------------- | ------ | ------ | ---- | -------------------------------------------------- | -| 001 | [Session lifecycle 原子提交](001-session-lifecycle-commit.md) | P1 | L | TODO | 持久化、整体投影、并发决胜和旧 writer 准入均有证据 | - -执行 001 时按「依赖实验 → 操作模型、两种存储与切换门槛 → 生产者与消费者迁移 → 产品验收」推进。 -前三阶段可以拆成可独立评审的变更,但不能把某个阶段通过当作 #574 已完成。 -主控负责模型、提交边界和启用决策;执行 Agent 负责界限明确的实现与验证。 - -## 已排除的方向 - -- 调整 root/child 补偿顺序:不能证明当前字段仍属于本次操作。 -- `Promise.all` 或只包一层 `flock.txn`:不提供完整的 repository 投影与跨副本操作决胜。 -- `operationId === mine` 后 blind rollback:本地检查不能排除未同步的其他 writer。 -- 扫描 root/child 布尔值后自动修复:无法区分合法独立 Tab 操作与失败残留。 -- 原始 flags 与 operation 长期双写为两个权威:故障和旧客户端会重新制造分裂。 -- 修改 weaker invariant 后直接关闭 #574:与 owning Spec 的操作原子性不一致。 - -状态由负责最终验收的主控更新。执行 Agent 的测试结果必须注明代码版本、实际 adapter、 -已执行的命令与尚未验证的边界,不以测试数量代替证据。 diff --git a/plans/support/inspect-lifecycle-boundaries.cjs b/plans/support/inspect-lifecycle-boundaries.cjs deleted file mode 100644 index 1e803a622..000000000 --- a/plans/support/inspect-lifecycle-boundaries.cjs +++ /dev/null @@ -1,133 +0,0 @@ -// Characterize the pinned dependencies with synthetic in-memory replicas. -// This intentionally asserts existing limitations, not production fix acceptance. -const assert = require('node:assert/strict'); -const { readFileSync } = require('node:fs'); -const { createRequire } = require('node:module'); -const { dirname, resolve } = require('node:path'); - -const manifest = resolve(process.argv[2] ?? 'packages/components/package.json'); -const dependencyRequire = createRequire(manifest); -const { Flock } = dependencyRequire('@loro-dev/flock-wasm'); -const { LoroRepo } = dependencyRequire('loro-repo'); -const versionOf = (name) => { - const entry = dependencyRequire.resolve(name); - return JSON.parse(readFileSync(resolve(dirname(entry), '..', 'package.json'), 'utf8')).version; -}; -const versions = { - repo: versionOf('loro-repo'), - wasm: versionOf('@loro-dev/flock-wasm'), -}; -assert.deepEqual(versions, { repo: '0.20.0', wasm: '0.4.3' }); - -const originalNow = Date.now; -Date.now = () => 1000; - -function inspectRollback() { - const local = new Flock('lifecycle-probe-local'); - const remote = new Flock('lifecycle-probe-remote'); - const root = ['m', 'session-root', 'isArchived']; - const child = ['m', 'session-child', 'isArchived']; - const status = ['m', 'session-child', 'status']; - local.txn(() => { - local.put(root, false, 1000); - local.put(child, false, 1000); - local.put(status, { type: 'running' }, 1000); - }); - remote.importJson(local.exportJson()); - remote.txn(() => { - remote.put(child, true, 2000); - remote.put(status, { type: 'requestPermission' }, 2000); - }); - local.importJson(remote.exportJson()); - const before = local.exportJson(); - const events = []; - local.subscribe((event) => events.push(event)); - assert.throws( - () => - local.txn(() => { - local.put(status, { type: 'idle' }, 3000); - local.put(root, true, 3000); - throw new Error('injected before commit'); - }), - /injected before commit/ - ); - assert.equal(local.get(root), false); - assert.equal(local.get(child), true); - assert.deepEqual(local.get(status), { type: 'requestPermission' }); - assert.deepEqual(local.exportJson(), before); - assert.equal(events.length, 0); - remote.importJson(local.exportJson()); - assert.deepEqual(remote.get(status), { type: 'requestPermission' }); - return { peerUpdatePreserved: true, exportUnchanged: true, emittedBatches: 0 }; -} - -async function inspectRepositoryPublication() { - const local = await LoroRepo.create({ metaDebounceCommitMs: 0 }); - let remote; - try { - remote = await LoroRepo.create({ metaDebounceCommitMs: 0 }); - await local.upsertDocMeta('session-child', { isArchived: false, parentSessionId: 'root' }); - await local.upsertDocMeta('session-root', { isArchived: false }); - remote.getMeta().importJson(local.getMeta().exportJson()); - // Pinned-version diagnostic only; product code must not depend on this private queue. - await remote.syncRunner.metaHydrationQueue; - await local.getDocMeta('session-root'); - await local.getDocMeta('session-child'); - const observations = []; - remote.watch((event) => { - if (event.kind !== 'doc-metadata') return; - observations.push( - Promise.all([remote.getDocMeta('session-child'), remote.getDocMeta('session-root')]).then( - ([child, root]) => ({ - eventDoc: event.docId, - child: child.meta.isArchived, - root: root.meta.isArchived, - }) - ) - ); - }); - local.getMeta().txn(() => { - local.getMeta().put(['m', 'session-child', 'isArchived'], true); - local.getMeta().put(['m', 'session-root', 'isArchived'], true); - }); - const raw = [ - local.getMeta().get(['m', 'session-child', 'isArchived']), - local.getMeta().get(['m', 'session-root', 'isArchived']), - ]; - const cached = [ - (await local.getDocMeta('session-child')).meta.isArchived, - (await local.getDocMeta('session-root')).meta.isArchived, - ]; - assert.deepEqual(raw, [true, true]); - assert.deepEqual(cached, [false, false]); - remote.getMeta().importJson(local.getMeta().exportJson()); - await remote.syncRunner.metaHydrationQueue; - const snapshots = await Promise.all(observations); - assert(snapshots.some((snapshot) => snapshot.child !== snapshot.root)); - assert.equal((await remote.getDocMeta('session-root')).meta.isArchived, true); - return { localRaw: raw, localCached: cached, remoteWatchSnapshots: snapshots }; - } finally { - await Promise.all([local.destroy(), remote?.destroy()]); - } -} - -(async () => { - try { - console.log( - JSON.stringify( - { - versions, - rollback: inspectRollback(), - publication: await inspectRepositoryPublication(), - }, - null, - 2 - ) - ); - } finally { - Date.now = originalNow; - } -})().catch((error) => { - console.error(error); - process.exitCode = 1; -}); diff --git a/specs/session-relations.md b/specs/session-relations.md index 9e2f748e2..17fa23477 100644 --- a/specs/session-relations.md +++ b/specs/session-relations.md @@ -135,25 +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). -The current archive implementation reads the repository metadata index for every -action, so an interactive root does not depend on the client projection having -discovered its direct children. The -query must complete before the first archive write, and query failure aborts the action -without mutation. Its result is complete for the repository snapshot observed by that -query; it is not a transaction boundary and does not include children created after the -snapshot. Restore still discovers direct children from the client metadata cache and -therefore retains the cold-start implementation gap. Archive still uses independent -writes and snapshot compensation; this does not implement the atomic lifecycle and -concurrent-write guarantees above. Existing resource reconciliation does not repair a -partially applied metadata transition. - -The replacement direction is a durable operation record with a shared, atomically -published projection. Its conflict ordering, persistence boundary, and mixed-client -rollout must pass the [implementation plan](../plans/001-session-lifecycle-commit.md) -before this draft can be treated as implemented. The -[decision proposal](../.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md) -records the dependency evidence and alternatives. Children created after the discovery -snapshot, recursive containment, and atomic permanent deletion are outside this change. +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 @@ -168,8 +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. 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 the full operation contract. Human approval of this draft remains pending. +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 index 30a4263ea..06427c763 100644 --- a/specs/session-relations.zh.md +++ b/specs/session-relations.zh.md @@ -76,12 +76,12 @@ Tab T 是 A 的组成部分。即使 Session B 和 C 是由 A 或 T 发起创建 本 Spec 不定义 worker 监管、状态或结果聚合、未读或权限路由、worker 面板、settle 或 handoff 行为。这些产品选择仍属于 [#529](https://github.com/LodyAI/Lody/issues/529) 的范围。 -当前归档实现会为每次操作读取仓库元数据索引,因此交互式根不依赖客户端投影是否发现了其直接子级。查询必须在首次归档写入前完成,查询失败会在不产生变更的情况下中止操作。其结果完整覆盖查询所观察到的仓库快照;它不是事务边界,也不包括快照之后创建的子级。恢复仍从客户端元数据缓存发现直接子级,因此保留冷启动实现缺口。归档仍使用独立写入和快照补偿;这并未实现上文的原子生命周期与并发写入保证。现有资源协调不会修复部分应用的元数据变更。 +归档与恢复现在都会在提交前读取同一个仓库元数据快照;查询或工作区所有权失败不会留下操作。在协调升级的本地 OSS 拓扑中,两种动作都会持久准入一个不可变操作,并通过仓库接缝投影其完整目标集合。浏览器准入使用工作区级 IndexedDB,CLI 准入使用专用工作区 SQLite 数据库;两者都会重放尚未发布的记录,而不改变其身份或排序。已有 archived flag 会成为确定性的 counter-zero baseline。本地启用后会拒绝直接写入遗留归档值;新建 Session 的初始 active 元数据仍然合法。 -替换方向是持久化操作记录与共享的原子发布投影。其冲突排序、持久化边界和混合客户端发布必须通过[实现计划](../plans/001-session-lifecycle-commit.md),之后本 draft 才能视为已实现。[决策提案](../.agents/notes/proposed/architecture/2026-09-13-session-lifecycle-commit.md)记录依赖证据和替代方案。本变更不涵盖发现快照之后创建的子级、递归包含以及永久删除的原子性。 +本地实现不涵盖发现快照之后创建的子级、递归包含、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)。 +报告的行为和支持的场景见 [#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 仍待人工批准。 +包含关系目标规则由 [#569](https://github.com/LodyAI/Lody/pull/569) 实现。归档接受标准仍由 [#574](https://github.com/LodyAI/Lody/issues/574) 跟踪;本地证据不能建立产品混合客户端兼容性。本 draft 仍待人工批准。