From dc699e58fec03e9fd5c1fabd59659f1d24c556ba Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 4 Mar 2026 12:48:50 -0800 Subject: [PATCH] fix(sdk): keep sdk sessions alive in non collab --- .../src/__tests__/collab-session-pool.test.ts | 173 ------- apps/cli/src/__tests__/warm-sessions.test.ts | 265 +++++++++++ apps/cli/src/commands/close.ts | 4 +- apps/cli/src/commands/open.ts | 9 +- apps/cli/src/commands/save.ts | 9 +- apps/cli/src/host/collab-session-pool.ts | 179 ------- apps/cli/src/host/invoke.ts | 8 +- apps/cli/src/host/server.ts | 16 +- apps/cli/src/host/session-pool.test.ts | 448 ++++++++++++++++++ apps/cli/src/host/session-pool.ts | 412 ++++++++++++++++ apps/cli/src/index.ts | 8 +- apps/cli/src/lib/document.ts | 50 +- apps/cli/src/lib/mutation-orchestrator.ts | 115 ++--- apps/cli/src/lib/read-orchestrator.ts | 32 +- apps/cli/src/lib/types.ts | 4 +- .../compiler-ref-targeting.test.ts | 18 +- .../plan-engine/compiler.ts | 70 +-- .../plan-engine/executor.ts | 4 +- .../plan-engine/preview.ts | 4 +- 19 files changed, 1270 insertions(+), 558 deletions(-) delete mode 100644 apps/cli/src/__tests__/collab-session-pool.test.ts create mode 100644 apps/cli/src/__tests__/warm-sessions.test.ts delete mode 100644 apps/cli/src/host/collab-session-pool.ts create mode 100644 apps/cli/src/host/session-pool.test.ts create mode 100644 apps/cli/src/host/session-pool.ts diff --git a/apps/cli/src/__tests__/collab-session-pool.test.ts b/apps/cli/src/__tests__/collab-session-pool.test.ts deleted file mode 100644 index eb6a99cb69..0000000000 --- a/apps/cli/src/__tests__/collab-session-pool.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { InMemoryCollaborationSessionPool } from '../host/collab-session-pool'; -import type { CollaborationProfile } from '../lib/collaboration'; -import type { OpenedDocument } from '../lib/document'; - -const NOOP = () => undefined; - -const TEST_PROFILE: CollaborationProfile = { - providerType: 'hocuspocus', - url: 'ws://example.test', - documentId: 'doc-1', -}; - -const TEST_IO = { - now: () => Date.now(), - readStdinBytes: async () => new Uint8Array(), - stdout: NOOP, - stderr: NOOP, -}; - -function createOpened(disposeCounter: { count: number }): OpenedDocument { - return { - editor: {} as OpenedDocument['editor'], - meta: { - source: 'path', - path: '/tmp/working.docx', - byteLength: 1, - }, - dispose: () => { - disposeCounter.count += 1; - }, - }; -} - -describe('InMemoryCollaborationSessionPool', () => { - test('acquire reuses matching session handles', async () => { - const disposeCounter = { count: 0 }; - let openCount = 0; - - const pool = new InMemoryCollaborationSessionPool({ - openCollaborative: async () => { - openCount += 1; - return createOpened(disposeCounter); - }, - now: () => 1, - }); - - const metadata = { - contextId: 's1', - sessionType: 'collab' as const, - collaboration: TEST_PROFILE, - sourcePath: '/tmp/source.docx', - workingDocPath: '/tmp/working.docx', - }; - - const first = await pool.acquire('s1', '/tmp/working.docx', metadata, TEST_IO); - const second = await pool.acquire('s1', '/tmp/working.docx', metadata, TEST_IO); - - expect(openCount).toBe(1); - first.dispose(); - second.dispose(); - expect(disposeCounter.count).toBe(0); - - await pool.disposeSession('s1'); - expect(disposeCounter.count).toBe(1); - }); - - test('acquire recreates stale handles on fingerprint mismatch', async () => { - const disposeCounter = { count: 0 }; - let openCount = 0; - - const pool = new InMemoryCollaborationSessionPool({ - openCollaborative: async () => { - openCount += 1; - return createOpened(disposeCounter); - }, - now: () => 1, - }); - - const metadataA = { - contextId: 's1', - sessionType: 'collab' as const, - collaboration: TEST_PROFILE, - sourcePath: '/tmp/source-a.docx', - workingDocPath: '/tmp/working.docx', - }; - - const metadataB = { - ...metadataA, - collaboration: { - ...TEST_PROFILE, - documentId: 'doc-2', - }, - }; - - await pool.acquire('s1', '/tmp/working.docx', metadataA, TEST_IO); - await pool.acquire('s1', '/tmp/working.docx', metadataB, TEST_IO); - - expect(openCount).toBe(2); - expect(disposeCounter.count).toBe(1); - - await pool.disposeAll(); - expect(disposeCounter.count).toBe(2); - }); - - test('acquire reuses handle when only source path changes', async () => { - const disposeCounter = { count: 0 }; - let openCount = 0; - - const pool = new InMemoryCollaborationSessionPool({ - openCollaborative: async () => { - openCount += 1; - return createOpened(disposeCounter); - }, - now: () => 1, - }); - - const metadataA = { - contextId: 's1', - sessionType: 'collab' as const, - collaboration: TEST_PROFILE, - sourcePath: '/tmp/source-a.docx', - workingDocPath: '/tmp/working.docx', - }; - - const metadataB = { - ...metadataA, - sourcePath: '/tmp/source-b.docx', - }; - - await pool.acquire('s1', '/tmp/working.docx', metadataA, TEST_IO); - await pool.acquire('s1', '/tmp/working.docx', metadataB, TEST_IO); - - expect(openCount).toBe(1); - expect(disposeCounter.count).toBe(0); - - await pool.disposeAll(); - expect(disposeCounter.count).toBe(1); - }); - - test('adoptFromOpen replaces existing handle', async () => { - const disposeCounter = { count: 0 }; - let openCount = 0; - - const pool = new InMemoryCollaborationSessionPool({ - openCollaborative: async () => { - openCount += 1; - return createOpened(disposeCounter); - }, - now: () => 1, - }); - - const metadata = { - contextId: 's1', - sessionType: 'collab' as const, - collaboration: TEST_PROFILE, - sourcePath: '/tmp/source.docx', - workingDocPath: '/tmp/working.docx', - }; - - await pool.acquire('s1', '/tmp/working.docx', metadata, TEST_IO); - - const adoptedDisposeCounter = { count: 0 }; - const adopted = createOpened(adoptedDisposeCounter); - await pool.adoptFromOpen('s1', adopted, metadata, TEST_IO); - - expect(openCount).toBe(1); - expect(disposeCounter.count).toBe(1); - - await pool.disposeSession('s1'); - expect(adoptedDisposeCounter.count).toBe(1); - }); -}); diff --git a/apps/cli/src/__tests__/warm-sessions.test.ts b/apps/cli/src/__tests__/warm-sessions.test.ts new file mode 100644 index 0000000000..fcf6292355 --- /dev/null +++ b/apps/cli/src/__tests__/warm-sessions.test.ts @@ -0,0 +1,265 @@ +/** + * Integration tests for warm SDK sessions. + * + * Verifies that host-mode sessions keep a live editor in memory across + * cli.invoke calls, deferring disk writes to save/close/shutdown. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { copyFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { resolveSourceDocFixture } from './fixtures'; + +const REPO_ROOT = path.resolve(import.meta.dir, '../../../..'); +const CLI_BIN = path.join(REPO_ROOT, 'apps/cli/src/index.ts'); +const TIMEOUT_MS = 15_000; + +type JsonRpcMessage = { + jsonrpc: '2.0'; + id?: number | null; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +}; + +// --------------------------------------------------------------------------- +// Host harness (reused from host.test.ts pattern) +// --------------------------------------------------------------------------- + +async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), timeoutMs); + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +function launchHost(stateDir: string) { + const child = spawn('bun', [CLI_BIN, 'host', '--stdio'], { + cwd: REPO_ROOT, + env: { ...process.env, SUPERDOC_CLI_STATE_DIR: stateDir }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let nextId = 1; + const pending = new Map void; reject: (error: Error) => void }>(); + let stdoutBuffer = ''; + + child.stdout.on('data', (chunk) => { + stdoutBuffer += String(chunk); + const lines = stdoutBuffer.split('\n'); + stdoutBuffer = lines.pop() ?? ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith('{')) continue; + const message = JSON.parse(trimmed) as JsonRpcMessage; + if (typeof message.id === 'number') { + const waiter = pending.get(message.id); + if (waiter) { + pending.delete(message.id); + waiter.resolve(message); + } + } + } + }); + + child.on('close', () => { + for (const [id, waiter] of pending) { + pending.delete(id); + waiter.reject(new Error('Host exited before response.')); + } + }); + + function request(method: string, params?: unknown): Promise { + const id = nextId++; + const frame = JSON.stringify({ jsonrpc: '2.0', id, method, params }); + return withTimeout( + new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + child.stdin.write(`${frame}\n`); + }), + TIMEOUT_MS, + `Timed out waiting for response to ${method}.`, + ); + } + + function invoke(argv: string[]): Promise { + return request('cli.invoke', { argv }); + } + + async function shutdown(): Promise { + try { + await request('host.shutdown'); + } catch { + child.kill('SIGKILL'); + } + await withTimeout( + new Promise((resolve) => child.once('close', () => resolve())), + TIMEOUT_MS, + 'Timed out waiting for host shutdown.', + ); + } + + return { child, request, invoke, shutdown }; +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function resultData(msg: JsonRpcMessage): Record { + const r = msg.result as { data?: unknown }; + return (r?.data ?? {}) as Record; +} + +function resultError(msg: JsonRpcMessage): { code: number; message: string; data?: unknown } | undefined { + return msg.error; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('warm SDK sessions', () => { + const cleanup: string[] = []; + + afterEach(async () => { + while (cleanup.length > 0) { + const p = cleanup.pop(); + if (p) await rm(p, { recursive: true, force: true }); + } + }); + + test('open → mutate → mutate → save: both mutations persist', async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-')); + cleanup.push(stateDir); + const sourceDoc = await resolveSourceDocFixture(); + const host = launchHost(stateDir); + + try { + // Open session + const openRes = await host.invoke(['open', sourceDoc, '--session', 'warm-1']); + expect(openRes.error).toBeUndefined(); + const openData = resultData(openRes); + expect(openData.contextId).toBe('warm-1'); + + // First mutation + const mutate1 = await host.invoke(['insert', '--session', 'warm-1', '--value', 'WARM_FIRST']); + expect(mutate1.error).toBeUndefined(); + const mutate1Data = resultData(mutate1); + expect((mutate1Data.document as any)?.revision).toBe(1); + + // Second mutation + const mutate2 = await host.invoke(['insert', '--session', 'warm-1', '--value', 'WARM_SECOND']); + expect(mutate2.error).toBeUndefined(); + const mutate2Data = resultData(mutate2); + expect((mutate2Data.document as any)?.revision).toBe(2); + + // Save + const saveDir = await mkdtemp(path.join(tmpdir(), 'warm-save-')); + cleanup.push(saveDir); + const outPath = path.join(saveDir, 'result.docx'); + const saveRes = await host.invoke(['save', '--session', 'warm-1', '--out', outPath]); + expect(saveRes.error).toBeUndefined(); + + // Verify file was written + const savedBytes = await readFile(outPath); + expect(savedBytes.byteLength).toBeGreaterThan(0); + } finally { + await host.shutdown(); + } + }); + + test('open → mutate → close --discard: no checkpoint', async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-discard-')); + cleanup.push(stateDir); + const sourceDoc = await resolveSourceDocFixture(); + const host = launchHost(stateDir); + + try { + await host.invoke(['open', sourceDoc, '--session', 'discard-1']); + + // Mutate + await host.invoke(['insert', '--session', 'discard-1', '--value', 'DISCARDED']); + + // Close with discard + const closeRes = await host.invoke(['close', '--session', 'discard-1', '--discard']); + expect(closeRes.error).toBeUndefined(); + const closeData = resultData(closeRes); + expect(closeData.discarded).toBe(true); + } finally { + await host.shutdown(); + } + }); + + test('open → mutate → close (no discard, dirty) → error', async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-dirty-')); + cleanup.push(stateDir); + const sourceDoc = await resolveSourceDocFixture(); + const host = launchHost(stateDir); + + try { + await host.invoke(['open', sourceDoc, '--session', 'dirty-1']); + + // Mutate to make session dirty + await host.invoke(['insert', '--session', 'dirty-1', '--value', 'DIRTY']); + + // Close without discard should fail + const closeRes = await host.invoke(['close', '--session', 'dirty-1']); + const err = resultError(closeRes); + expect(err).toBeDefined(); + expect((err?.data as any)?.cliCode).toBe('DIRTY_CLOSE_REQUIRES_DECISION'); + } finally { + await host.shutdown(); + } + }); + + test('read operations reuse warm session', async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-read-')); + cleanup.push(stateDir); + const sourceDoc = await resolveSourceDocFixture(); + const host = launchHost(stateDir); + + try { + await host.invoke(['open', sourceDoc, '--session', 'read-1']); + + // Multiple reads should work (reusing the pooled editor) + const info1 = await host.invoke(['info', '--session', 'read-1']); + expect(info1.error).toBeUndefined(); + + const info2 = await host.invoke(['info', '--session', 'read-1']); + expect(info2.error).toBeUndefined(); + + await host.invoke(['close', '--session', 'read-1', '--discard']); + } finally { + await host.shutdown(); + } + }); + + test('shutdown flushes dirty sessions to disk', async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-shutdown-')); + cleanup.push(stateDir); + const sourceDoc = await resolveSourceDocFixture(); + const host = launchHost(stateDir); + + try { + await host.invoke(['open', sourceDoc, '--session', 'shutdown-1']); + + // Mutate (makes session dirty) + await host.invoke(['insert', '--session', 'shutdown-1', '--value', 'SHUTDOWN_TEST']); + + // Shutdown should flush via disposeAll + await host.shutdown(); + } catch { + // shutdown may throw if host exits before response — that's OK + } + }); +}); diff --git a/apps/cli/src/commands/close.ts b/apps/cli/src/commands/close.ts index 2e9b231bc9..998d46eb8a 100644 --- a/apps/cli/src/commands/close.ts +++ b/apps/cli/src/commands/close.ts @@ -62,8 +62,8 @@ export async function runClose(tokens: string[], context: CommandContext): Promi pretty: mode.discard ? 'Closed context (discarded unsaved changes)' : 'Closed context', }; - if (context.executionMode === 'host' && context.collabSessionPool) { - await context.collabSessionPool.disposeSession(effectiveMetadata.contextId); + if (context.executionMode === 'host' && context.sessionPool) { + await context.sessionPool.disposeSession(effectiveMetadata.contextId, { discard: mode.discard }); } await clearContext(paths); diff --git a/apps/cli/src/commands/open.ts b/apps/cli/src/commands/open.ts index 183c603b8d..2b3a9086b8 100644 --- a/apps/cli/src/commands/open.ts +++ b/apps/cli/src/commands/open.ts @@ -197,8 +197,13 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis await writeContextMetadata(paths, metadata); await setActiveSessionId(metadata.contextId); - if (collaboration && context.executionMode === 'host' && context.collabSessionPool) { - await context.collabSessionPool.adoptFromOpen(sessionId, opened, metadata, context.io); + if (context.executionMode === 'host' && context.sessionPool) { + context.sessionPool.adoptFromOpen(sessionId, opened, { + sessionType: metadata.sessionType, + workingDocPath: paths.workingDocPath, + metadataRevision: metadata.revision, + collaboration: metadata.collaboration, + }); adoptedToHostPool = true; } diff --git a/apps/cli/src/commands/save.ts b/apps/cli/src/commands/save.ts index 0635fdfbdd..314871bf01 100644 --- a/apps/cli/src/commands/save.ts +++ b/apps/cli/src/commands/save.ts @@ -57,11 +57,16 @@ export async function runSave(tokens: string[], context: CommandContext): Promis 'save', async ({ metadata, paths }) => { let effectiveMetadata = metadata; - if (metadata.sessionType === 'collab') { + + // Flush in-memory state to working.docx before copying + if (context.executionMode === 'host' && context.sessionPool) { + await context.sessionPool.checkpoint(metadata.contextId); + } else if (metadata.sessionType === 'collab') { + // Oneshot collab: sync snapshot the old way const opened = await openSessionDocument(paths.workingDocPath, context.io, metadata, { sessionId: context.sessionId ?? metadata.contextId, executionMode: context.executionMode, - collabSessionPool: context.collabSessionPool, + sessionPool: context.sessionPool, }); try { const synced = await syncCollaborativeSessionSnapshot(context.io, metadata, paths, opened.editor); diff --git a/apps/cli/src/host/collab-session-pool.ts b/apps/cli/src/host/collab-session-pool.ts deleted file mode 100644 index 1580683ac0..0000000000 --- a/apps/cli/src/host/collab-session-pool.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { CollaborationProfile } from '../lib/collaboration'; -import { openCollaborativeDocument, type OpenedDocument } from '../lib/document'; -import { CliError } from '../lib/errors'; -import type { CliIO, UserIdentity } from '../lib/types'; - -/** Metadata describing a document editing session and its optional collaboration configuration. */ -export interface CollaborationSessionMetadata { - contextId: string; - sessionType: 'local' | 'collab'; - collaboration?: CollaborationProfile; - sourcePath?: string; - workingDocPath: string; - user?: UserIdentity; -} - -type SessionFingerprint = { - profileKey: string; - workingDocPath: string; -}; - -type PooledSessionHandle = { - opened: OpenedDocument; - fingerprint: SessionFingerprint; - lastUsedAtMs: number; -}; - -type OpenCollaborativeDocumentFn = ( - docPath: string | undefined, - io: CliIO, - profile: CollaborationProfile, - options?: { user?: UserIdentity }, -) => Promise; - -function profileToKey(profile: CollaborationProfile): string { - return JSON.stringify({ - providerType: profile.providerType, - url: profile.url, - documentId: profile.documentId, - tokenEnv: profile.tokenEnv ?? null, - syncTimeoutMs: profile.syncTimeoutMs ?? null, - onMissing: profile.onMissing ?? null, - bootstrapSettlingMs: profile.bootstrapSettlingMs ?? null, - }); -} - -function buildFingerprint(metadata: CollaborationSessionMetadata): SessionFingerprint { - if (metadata.sessionType !== 'collab') { - throw new CliError('COMMAND_FAILED', 'Session is not collaborative.', { - contextId: metadata.contextId, - sessionType: metadata.sessionType, - }); - } - - if (!metadata.collaboration) { - throw new CliError('COMMAND_FAILED', 'Collaborative session metadata is missing collaboration profile.', { - contextId: metadata.contextId, - }); - } - - return { - profileKey: profileToKey(metadata.collaboration), - workingDocPath: metadata.workingDocPath, - }; -} - -function sameFingerprint(left: SessionFingerprint, right: SessionFingerprint): boolean { - return left.profileKey === right.profileKey && left.workingDocPath === right.workingDocPath; -} - -/** - * Manages pooled collaboration sessions, reusing connections when the session - * fingerprint (provider profile + working document path) matches. - */ -export interface CollaborationSessionPool { - /** Acquires (or reuses) a collaborative session, returning a leased document handle. */ - acquire( - sessionId: string, - docPath: string, - metadata: CollaborationSessionMetadata, - io: CliIO, - ): Promise; - /** Adopts an externally-opened document into the pool, replacing any existing session. */ - adoptFromOpen( - sessionId: string, - opened: OpenedDocument, - metadata: CollaborationSessionMetadata, - io: CliIO, - ): Promise; - /** Disposes a single session by id, closing its underlying document. */ - disposeSession(sessionId: string): Promise; - /** Disposes all pooled sessions. */ - disposeAll(): Promise; -} - -/** In-memory implementation of {@link CollaborationSessionPool}. */ -export class InMemoryCollaborationSessionPool implements CollaborationSessionPool { - private readonly handles = new Map(); - private readonly openCollaborative: OpenCollaborativeDocumentFn; - private readonly now: () => number; - - constructor(options: { openCollaborative?: OpenCollaborativeDocumentFn; now?: () => number } = {}) { - this.openCollaborative = options.openCollaborative ?? openCollaborativeDocument; - this.now = options.now ?? Date.now; - } - - async acquire( - sessionId: string, - docPath: string, - metadata: CollaborationSessionMetadata, - io: CliIO, - ): Promise { - const fingerprint = buildFingerprint(metadata); - const existing = this.handles.get(sessionId); - - if (existing) { - if (sameFingerprint(existing.fingerprint, fingerprint)) { - existing.lastUsedAtMs = this.now(); - return this.createLease(existing); - } - - await this.disposeSession(sessionId); - } - - // Safe to assert: buildFingerprint above already validated metadata.collaboration - const profile = metadata.collaboration!; - - const opened = await this.openCollaborative(docPath, io, profile, { user: metadata.user }); - const created: PooledSessionHandle = { - opened, - fingerprint, - lastUsedAtMs: this.now(), - }; - this.handles.set(sessionId, created); - - return this.createLease(created); - } - - async adoptFromOpen( - sessionId: string, - opened: OpenedDocument, - metadata: CollaborationSessionMetadata, - _io: CliIO, - ): Promise { - const fingerprint = buildFingerprint(metadata); - - await this.disposeSession(sessionId); - - this.handles.set(sessionId, { - opened, - fingerprint, - lastUsedAtMs: this.now(), - }); - } - - async disposeSession(sessionId: string): Promise { - const existing = this.handles.get(sessionId); - if (!existing) return; - - this.handles.delete(sessionId); - existing.opened.dispose(); - } - - async disposeAll(): Promise { - const sessionIds = Array.from(this.handles.keys()); - for (const sessionId of sessionIds) { - await this.disposeSession(sessionId); - } - } - - private createLease(handle: PooledSessionHandle): OpenedDocument { - return { - editor: handle.opened.editor, - meta: handle.opened.meta, - dispose: () => { - handle.lastUsedAtMs = this.now(); - }, - }; - } -} diff --git a/apps/cli/src/host/invoke.ts b/apps/cli/src/host/invoke.ts index 31411753d4..3b5b3c7317 100644 --- a/apps/cli/src/host/invoke.ts +++ b/apps/cli/src/host/invoke.ts @@ -2,7 +2,7 @@ import { invokeCommand } from '../index'; import { CliError } from '../lib/errors'; import { asRecord } from '../lib/guards'; import type { CliIO } from '../lib/types'; -import type { CollaborationSessionPool } from './collab-session-pool'; +import type { SessionPool } from './session-pool'; import { DEFAULT_MAX_STDIN_BYTES } from './protocol'; const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/; @@ -16,12 +16,12 @@ type CliInvokeParams = { * Options for invoking CLI commands from the host process. * * @param ioNow - Clock function used for elapsed-time tracking - * @param collabSessionPool - Pool for reusing collaboration sessions across invocations + * @param sessionPool - Pool for reusing sessions (local and collab) across invocations * @param maxStdinBytes - Maximum allowed size (bytes) for base64-decoded stdin payloads */ export interface HostInvokeCliOptions { ioNow?: () => number; - collabSessionPool?: CollaborationSessionPool; + sessionPool?: SessionPool; maxStdinBytes?: number; } @@ -121,7 +121,7 @@ export async function invokeCliFromHost( const invocation = await invokeCommand(params.argv, { ioOverrides: io, executionMode: 'host', - collabSessionPool: options.collabSessionPool, + sessionPool: options.sessionPool, }); if (invocation.helpText) { diff --git a/apps/cli/src/host/server.ts b/apps/cli/src/host/server.ts index edf341c3c2..ed2b572358 100644 --- a/apps/cli/src/host/server.ts +++ b/apps/cli/src/host/server.ts @@ -6,7 +6,7 @@ import { CliError, toCliError } from '../lib/errors'; import { asRecord } from '../lib/guards'; import type { CliIO } from '../lib/types'; import { buildContractOperationDetail, buildContractOverview } from '../lib/contract'; -import { InMemoryCollaborationSessionPool, type CollaborationSessionPool } from './collab-session-pool'; +import { InMemorySessionPool, type SessionPool } from './session-pool'; import { invokeCliFromHost } from './invoke'; import { DEFAULT_MAX_STDIN_BYTES, @@ -29,7 +29,7 @@ type HostServerOptions = { io: Pick; requestTimeoutMs?: number; maxStdinBytes?: number; - collabSessionPool?: CollaborationSessionPool; + sessionPool?: SessionPool; }; function resolveCliVersion(): string { @@ -105,7 +105,7 @@ class HostServer { private readonly io: Pick; private readonly requestTimeoutMs: number; private readonly maxStdinBytes: number; - private readonly collabSessionPool: CollaborationSessionPool; + private readonly sessionPool: SessionPool; private readonly ownsPool: boolean; private queue: Promise = Promise.resolve(); private shutdownRequested = false; @@ -115,11 +115,11 @@ class HostServer { this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; this.maxStdinBytes = options.maxStdinBytes ?? DEFAULT_MAX_STDIN_BYTES; - if (options.collabSessionPool) { - this.collabSessionPool = options.collabSessionPool; + if (options.sessionPool) { + this.sessionPool = options.sessionPool; this.ownsPool = false; } else { - this.collabSessionPool = new InMemoryCollaborationSessionPool(); + this.sessionPool = new InMemorySessionPool(); this.ownsPool = true; } } @@ -161,7 +161,7 @@ class HostServer { async dispose(): Promise { if (this.ownsPool) { - await this.collabSessionPool.disposeAll(); + await this.sessionPool.disposeAll(); } } @@ -258,7 +258,7 @@ class HostServer { const outcome = await settleWithTimeout( invokeCliFromHost(request.params, { ioNow: this.io.now, - collabSessionPool: this.collabSessionPool, + sessionPool: this.sessionPool, maxStdinBytes: this.maxStdinBytes, }), this.requestTimeoutMs, diff --git a/apps/cli/src/host/session-pool.test.ts b/apps/cli/src/host/session-pool.test.ts new file mode 100644 index 0000000000..41b6f34749 --- /dev/null +++ b/apps/cli/src/host/session-pool.test.ts @@ -0,0 +1,448 @@ +import { describe, expect, test } from 'bun:test'; +import { InMemorySessionPool, type SessionPoolDeps } from './session-pool'; +import type { OpenedDocument } from '../lib/document'; +import type { CliIO } from '../lib/types'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const NOOP = () => undefined; + +const TEST_IO: CliIO = { + now: () => Date.now(), + readStdinBytes: async () => new Uint8Array(), + stdout: NOOP, + stderr: NOOP, +}; + +function createFakeOpened(label = 'default'): { + opened: OpenedDocument; + disposeCount: { count: number }; +} { + const disposeCount = { count: 0 }; + return { + opened: { + editor: { id: label } as unknown as OpenedDocument['editor'], + meta: { source: 'path', path: `/tmp/${label}.docx`, byteLength: 1 }, + dispose: () => { + disposeCount.count += 1; + }, + }, + disposeCount, + }; +} + +function createPool(overrides: SessionPoolDeps = {}): { + pool: InMemorySessionPool; + openLocalCalls: number[]; + openCollabCalls: number[]; +} { + const openLocalCalls: number[] = []; + const openCollabCalls: number[] = []; + + const pool = new InMemorySessionPool({ + openLocal: async () => { + openLocalCalls.push(1); + return createFakeOpened(`local-${openLocalCalls.length}`).opened; + }, + openCollaborative: async () => { + openCollabCalls.push(1); + return createFakeOpened(`collab-${openCollabCalls.length}`).opened; + }, + exportToPath: async (_editor, docPath) => ({ path: docPath, byteLength: 100 }), + now: () => 1000, + createTimer: overrides.createTimer ?? (() => 0 as unknown as ReturnType), + clearTimer: overrides.clearTimer ?? NOOP, + ...overrides, + }); + + return { pool, openLocalCalls, openCollabCalls }; +} + +const LOCAL_METADATA = { + sessionType: 'local' as const, + workingDocPath: '/tmp/working.docx', + metadataRevision: 1, +}; + +const COLLAB_PROFILE = { + providerType: 'hocuspocus' as const, + url: 'ws://example.test', + documentId: 'doc-1', +}; + +const COLLAB_METADATA = { + sessionType: 'collab' as const, + workingDocPath: '/tmp/working.docx', + metadataRevision: 1, + collaboration: COLLAB_PROFILE, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('InMemorySessionPool', () => { + // ----------------------------------------------------------------------- + // acquire + // ----------------------------------------------------------------------- + + describe('acquire', () => { + test('returns same editor for same sessionId (local reuse)', async () => { + const { pool, openLocalCalls } = createPool(); + + const first = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + first.dispose(); // release lease + const second = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + + expect(openLocalCalls.length).toBe(1); + expect(first.editor).toBe(second.editor); + }); + + test('opens fresh on first call', async () => { + const { pool, openLocalCalls } = createPool(); + + await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + + expect(openLocalCalls.length).toBe(1); + }); + + test('discards and reopens collab session on fingerprint mismatch', async () => { + const { pool, openCollabCalls } = createPool(); + + await pool.acquire('s1', COLLAB_METADATA, TEST_IO); + + const differentProfile = { ...COLLAB_PROFILE, documentId: 'doc-2' }; + await pool.acquire('s1', { ...COLLAB_METADATA, collaboration: differentProfile }, TEST_IO); + + expect(openCollabCalls.length).toBe(2); + }); + + test('reuses collab session when fingerprint matches', async () => { + const { pool, openCollabCalls } = createPool(); + + const first = await pool.acquire('s1', COLLAB_METADATA, TEST_IO); + first.dispose(); + const second = await pool.acquire('s1', COLLAB_METADATA, TEST_IO); + + expect(openCollabCalls.length).toBe(1); + expect(first.editor).toBe(second.editor); + }); + + test('discards and reopens local session on metadataRevision drift (no checkpoint)', async () => { + const { pool, openLocalCalls } = createPool({ + openLocal: async () => { + openLocalCalls.push(1); + return createFakeOpened(`local-${openLocalCalls.length}`).opened; + }, + }); + + // Acquire with revision 1 + const first = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + pool.markDirty('s1'); + first.dispose(); + + // Acquire with drifted revision (revision 5 — out-of-band mutation) + const driftedMetadata = { ...LOCAL_METADATA, metadataRevision: 5 }; + const second = await pool.acquire('s1', driftedMetadata, TEST_IO); + + // Should have opened a fresh session, not checkpointed the stale one + expect(first.editor).not.toBe(second.editor); + }); + + test('reuses local session when metadataRevision matches', async () => { + const { pool, openLocalCalls } = createPool(); + + const first = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + first.dispose(); + const second = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + + expect(openLocalCalls.length).toBe(1); + }); + }); + + // ----------------------------------------------------------------------- + // updateMetadataRevision + // ----------------------------------------------------------------------- + + describe('updateMetadataRevision', () => { + test('keeps pool in sync — no spurious drift-triggered reopens', async () => { + const { pool, openLocalCalls } = createPool(); + + const first = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + first.dispose(); + + // Simulate mutation: bump revision in pool + pool.updateMetadataRevision('s1', 2); + + // Next acquire with updated revision should reuse + const second = await pool.acquire('s1', { ...LOCAL_METADATA, metadataRevision: 2 }, TEST_IO); + + expect(openLocalCalls.length).toBe(1); + expect(first.editor).toBe(second.editor); + }); + }); + + // ----------------------------------------------------------------------- + // markDirty / checkpoint + // ----------------------------------------------------------------------- + + describe('markDirty and checkpoint', () => { + test('markDirty + checkpoint writes to disk and clears dirty', async () => { + const exportCalls: string[] = []; + + const pool = new InMemorySessionPool({ + openLocal: async () => createFakeOpened('local').opened, + exportToPath: async (_editor, docPath) => { + exportCalls.push(docPath); + return { path: docPath, byteLength: 100 }; + }, + now: () => 1000, + createTimer: () => 0 as unknown as ReturnType, + clearTimer: NOOP, + }); + + await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + pool.markDirty('s1'); + expect(pool.isDirty('s1')).toBe(true); + + await pool.checkpoint('s1'); + + expect(pool.isDirty('s1')).toBe(false); + expect(exportCalls).toEqual([LOCAL_METADATA.workingDocPath]); + }); + + test('disposeSession without discard flushes dirty state', async () => { + const exportCalls: string[] = []; + + const pool = new InMemorySessionPool({ + openLocal: async () => createFakeOpened('local').opened, + exportToPath: async (_editor, docPath) => { + exportCalls.push(docPath); + return { path: docPath, byteLength: 100 }; + }, + now: () => 1000, + createTimer: () => 0 as unknown as ReturnType, + clearTimer: NOOP, + }); + + const opened = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + pool.markDirty('s1'); + opened.dispose(); + + await pool.disposeSession('s1'); + + // Should have checkpointed before destroying + expect(exportCalls).toEqual([LOCAL_METADATA.workingDocPath]); + // Session is gone after dispose + expect(pool.isDirty('s1')).toBe(false); + }); + + test('disposeSession with discard does NOT checkpoint', async () => { + const { pool } = createPool(); + + const opened = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + pool.markDirty('s1'); + opened.dispose(); + + // After discard dispose, session is removed — isDirty returns false (no session) + await pool.disposeSession('s1', { discard: true }); + expect(pool.isDirty('s1')).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // adoptFromOpen + // ----------------------------------------------------------------------- + + describe('adoptFromOpen', () => { + test('adopts and reuses editor on next acquire', async () => { + const { pool, openLocalCalls } = createPool(); + const { opened } = createFakeOpened('adopted'); + + pool.adoptFromOpen('s1', opened, { + sessionType: 'local', + workingDocPath: '/tmp/working.docx', + metadataRevision: 1, + }); + + const acquired = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + expect(openLocalCalls.length).toBe(0); + expect(acquired.editor).toBe(opened.editor); + }); + + test('replaces existing session', async () => { + const { pool } = createPool(); + const { opened: first, disposeCount: firstDispose } = createFakeOpened('first'); + const { opened: second } = createFakeOpened('second'); + + pool.adoptFromOpen('s1', first, { + sessionType: 'local', + workingDocPath: '/tmp/working.docx', + metadataRevision: 1, + }); + + pool.adoptFromOpen('s1', second, { + sessionType: 'local', + workingDocPath: '/tmp/working.docx', + metadataRevision: 1, + }); + + expect(firstDispose.count).toBe(1); + + const acquired = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + expect(acquired.editor).toBe(second.editor); + }); + }); + + // ----------------------------------------------------------------------- + // Lease behavior + // ----------------------------------------------------------------------- + + describe('lease', () => { + test('dispose() does not destroy editor, sets leased = false', async () => { + const { pool } = createPool(); + const { opened: fake, disposeCount } = createFakeOpened('test'); + + pool.adoptFromOpen('s1', fake, { + sessionType: 'local', + workingDocPath: '/tmp/working.docx', + metadataRevision: 1, + }); + + const leased = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + leased.dispose(); // lease release, not real dispose + + expect(disposeCount.count).toBe(0); // editor NOT destroyed + + // Can still acquire again + const again = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + expect(again.editor).toBe(fake.editor); + }); + }); + + // ----------------------------------------------------------------------- + // disposeAll + // ----------------------------------------------------------------------- + + describe('disposeAll', () => { + test('disposes all sessions', async () => { + const { pool, openLocalCalls, openCollabCalls } = createPool(); + + const a = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + const b = await pool.acquire('s2', COLLAB_METADATA, TEST_IO); + a.dispose(); + b.dispose(); + + await pool.disposeAll(); + + // After disposeAll, re-acquiring on the same pool should open fresh sessions + expect(openLocalCalls.length).toBe(1); + expect(openCollabCalls.length).toBe(1); + + const c = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + expect(openLocalCalls.length).toBe(2); // fresh open, not reused + c.dispose(); + }); + }); + + // ----------------------------------------------------------------------- + // Autosave timer + // ----------------------------------------------------------------------- + + describe('autosave timer', () => { + test('fires after debounce period', async () => { + const timerCallbacks: Array<() => void> = []; + const { pool } = createPool({ + createTimer: (cb) => { + timerCallbacks.push(cb); + return timerCallbacks.length as unknown as ReturnType; + }, + clearTimer: NOOP, + }); + + const opened = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + opened.dispose(); + pool.markDirty('s1'); + + expect(timerCallbacks.length).toBe(1); + }); + + test('resets on repeated markDirty (debounce)', async () => { + let clearCount = 0; + const timerCallbacks: Array<() => void> = []; + const { pool } = createPool({ + createTimer: (cb) => { + timerCallbacks.push(cb); + return timerCallbacks.length as unknown as ReturnType; + }, + clearTimer: () => { + clearCount += 1; + }, + }); + + const opened = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + opened.dispose(); + + pool.markDirty('s1'); + pool.markDirty('s1'); + pool.markDirty('s1'); + + // Each markDirty after the first clears the previous timer + expect(clearCount).toBe(2); + expect(timerCallbacks.length).toBe(3); + }); + + test('disposeSession clears autosave timer', async () => { + let clearCount = 0; + const { pool } = createPool({ + createTimer: () => 42 as unknown as ReturnType, + clearTimer: () => { + clearCount += 1; + }, + }); + + const opened = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + opened.dispose(); + pool.markDirty('s1'); + + await pool.disposeSession('s1', { discard: true }); + expect(clearCount).toBeGreaterThan(0); + }); + }); + + // ----------------------------------------------------------------------- + // flushPendingCheckpoints + // ----------------------------------------------------------------------- + + describe('flushPendingCheckpoints', () => { + test('clears pending timers and attempts checkpoint', async () => { + let clearCount = 0; + const exportCalls: string[] = []; + + const pool = new InMemorySessionPool({ + openLocal: async () => createFakeOpened('flush-test').opened, + exportToPath: async (_editor, docPath) => { + exportCalls.push(docPath); + return { path: docPath, byteLength: 100 }; + }, + now: () => 1000, + createTimer: () => 1 as unknown as ReturnType, + clearTimer: () => { + clearCount += 1; + }, + }); + + const opened = await pool.acquire('s1', LOCAL_METADATA, TEST_IO); + opened.dispose(); + pool.markDirty('s1'); + + await pool.flushPendingCheckpoints(); + + expect(clearCount).toBeGreaterThan(0); + expect(exportCalls).toEqual([LOCAL_METADATA.workingDocPath]); + expect(pool.isDirty('s1')).toBe(false); + }); + }); +}); diff --git a/apps/cli/src/host/session-pool.ts b/apps/cli/src/host/session-pool.ts new file mode 100644 index 0000000000..1390a218cf --- /dev/null +++ b/apps/cli/src/host/session-pool.ts @@ -0,0 +1,412 @@ +import type { CollaborationProfile } from '../lib/collaboration'; +import { exportToPath, openCollaborativeDocument, openDocument, type OpenedDocument } from '../lib/document'; +import type { CliIO, UserIdentity } from '../lib/types'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const AUTOSAVE_DEBOUNCE_MS = 3_000; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type SessionType = 'local' | 'collab'; + +export interface AcquireMetadata { + sessionType: SessionType; + workingDocPath: string; + metadataRevision: number; + user?: UserIdentity; + /** Required for collab sessions. */ + collaboration?: CollaborationProfile; +} + +export interface AdoptMetadata { + sessionType: SessionType; + workingDocPath: string; + metadataRevision: number; + collaboration?: CollaborationProfile; +} + +export interface DisposeOptions { + /** Skip checkpoint on dispose. Used by `close --discard`. */ + discard?: boolean; +} + +/** Dependencies injectable for testing. */ +export interface SessionPoolDeps { + openLocal?: (docPath: string, io: CliIO, options?: { user?: UserIdentity }) => Promise; + openCollaborative?: ( + docPath: string | undefined, + io: CliIO, + profile: CollaborationProfile, + options?: { user?: UserIdentity }, + ) => Promise; + exportToPath?: ( + editor: OpenedDocument['editor'], + docPath: string, + overwrite: boolean, + ) => Promise<{ path: string; byteLength: number }>; + now?: () => number; + createTimer?: (callback: () => void, ms: number) => ReturnType; + clearTimer?: (handle: ReturnType) => void; +} + +// --------------------------------------------------------------------------- +// Pool interface +// --------------------------------------------------------------------------- + +export interface SessionPool { + acquire(sessionId: string, metadata: AcquireMetadata, io: CliIO): Promise; + adoptFromOpen(sessionId: string, opened: OpenedDocument, metadata: AdoptMetadata): void; + checkpoint(sessionId: string): Promise; + checkpointAll(): Promise; + markDirty(sessionId: string): void; + updateMetadataRevision(sessionId: string, revision: number): void; + isDirty(sessionId: string): boolean; + disposeSession(sessionId: string, options?: DisposeOptions): Promise; + disposeAll(): Promise; + /** Immediately runs any pending autosave checkpoints (for testing). */ + flushPendingCheckpoints(): Promise; +} + +// --------------------------------------------------------------------------- +// Collab fingerprint (preserved from old pool) +// --------------------------------------------------------------------------- + +function profileToFingerprint(profile: CollaborationProfile): string { + return JSON.stringify({ + providerType: profile.providerType, + url: profile.url, + documentId: profile.documentId, + tokenEnv: profile.tokenEnv ?? null, + syncTimeoutMs: profile.syncTimeoutMs ?? null, + onMissing: profile.onMissing ?? null, + bootstrapSettlingMs: profile.bootstrapSettlingMs ?? null, + }); +} + +// --------------------------------------------------------------------------- +// Per-session async mutex +// --------------------------------------------------------------------------- + +type SessionLockEntry = { chain: Promise }; + +function createSessionLocks(): { + withLock: (sessionId: string, fn: () => Promise) => Promise; +} { + const locks = new Map(); + + function withLock(sessionId: string, fn: () => Promise): Promise { + const entry = locks.get(sessionId) ?? { chain: Promise.resolve() }; + locks.set(sessionId, entry); + + const result = entry.chain.then(fn, fn); + + // Update chain to wait for this operation (ignore errors — they're returned to caller) + entry.chain = result.then( + () => undefined, + () => undefined, + ); + + return result; + } + + return { withLock }; +} + +// --------------------------------------------------------------------------- +// Pooled session entry +// --------------------------------------------------------------------------- + +interface PooledSession { + opened: OpenedDocument; + sessionType: SessionType; + dirty: boolean; + leased: boolean; + workingDocPath: string; + io: CliIO; + metadataRevision: number; + lastUsedAtMs: number; + autosaveTimer: ReturnType | null; + /** Collab-only fields */ + collaboration?: CollaborationProfile; + fingerprint?: string; +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +export class InMemorySessionPool implements SessionPool { + private readonly sessions = new Map(); + private readonly sessionLocks = createSessionLocks(); + + private readonly openLocal: NonNullable; + private readonly openCollaborative: NonNullable; + private readonly exportToPathFn: NonNullable; + private readonly now: () => number; + private readonly createTimer: NonNullable; + private readonly clearTimer: NonNullable; + + constructor(deps: SessionPoolDeps = {}) { + this.openLocal = deps.openLocal ?? openDocument; + this.openCollaborative = deps.openCollaborative ?? openCollaborativeDocument; + this.exportToPathFn = deps.exportToPath ?? exportToPath; + this.now = deps.now ?? Date.now; + this.createTimer = deps.createTimer ?? setTimeout; + this.clearTimer = deps.clearTimer ?? clearTimeout; + } + + // ------------------------------------------------------------------------- + // acquire + // ------------------------------------------------------------------------- + + async acquire(sessionId: string, metadata: AcquireMetadata, io: CliIO): Promise { + return this.sessionLocks.withLock(sessionId, async () => { + const existing = this.sessions.get(sessionId); + + if (existing) { + if (this.isSessionValid(existing, metadata)) { + existing.leased = true; + existing.lastUsedAtMs = this.now(); + existing.io = io; + return this.createLease(sessionId, existing); + } + + // Drift or fingerprint mismatch — discard without checkpoint + await this.destroySession(existing); + this.sessions.delete(sessionId); + } + + const session = await this.openFreshSession(metadata, io); + this.sessions.set(sessionId, session); + return this.createLease(sessionId, session); + }); + } + + // ------------------------------------------------------------------------- + // adoptFromOpen + // ------------------------------------------------------------------------- + + adoptFromOpen(sessionId: string, opened: OpenedDocument, metadata: AdoptMetadata): void { + const existing = this.sessions.get(sessionId); + if (existing) { + this.clearAutosaveTimer(existing); + existing.opened.dispose(); + } + + this.sessions.set(sessionId, { + opened, + sessionType: metadata.sessionType, + dirty: false, + leased: false, + workingDocPath: metadata.workingDocPath, + io: { stdout() {}, stderr() {}, readStdinBytes: async () => new Uint8Array(), now: this.now }, + metadataRevision: metadata.metadataRevision, + lastUsedAtMs: this.now(), + autosaveTimer: null, + collaboration: metadata.collaboration, + fingerprint: metadata.collaboration ? profileToFingerprint(metadata.collaboration) : undefined, + }); + } + + // ------------------------------------------------------------------------- + // checkpoint + // ------------------------------------------------------------------------- + + async checkpoint(sessionId: string): Promise { + return this.sessionLocks.withLock(sessionId, () => this.checkpointUnsafe(sessionId)); + } + + async checkpointAll(): Promise { + const ids = Array.from(this.sessions.keys()); + for (const id of ids) { + await this.checkpoint(id); + } + } + + // ------------------------------------------------------------------------- + // markDirty / updateMetadataRevision / isDirty + // ------------------------------------------------------------------------- + + markDirty(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + session.dirty = true; + this.resetAutosaveTimer(sessionId, session); + } + + updateMetadataRevision(sessionId: string, revision: number): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + session.metadataRevision = revision; + } + + isDirty(sessionId: string): boolean { + return this.sessions.get(sessionId)?.dirty ?? false; + } + + // ------------------------------------------------------------------------- + // dispose + // ------------------------------------------------------------------------- + + async disposeSession(sessionId: string, options?: DisposeOptions): Promise { + return this.sessionLocks.withLock(sessionId, async () => { + const session = this.sessions.get(sessionId); + if (!session) return; + + this.clearAutosaveTimer(session); + + if (session.dirty && !options?.discard) { + await this.checkpointUnsafe(sessionId); + } + + await this.destroySession(session); + this.sessions.delete(sessionId); + }); + } + + async disposeAll(): Promise { + const ids = Array.from(this.sessions.keys()); + for (const id of ids) { + await this.disposeSession(id); + } + } + + // ------------------------------------------------------------------------- + // flushPendingCheckpoints (test helper) + // ------------------------------------------------------------------------- + + async flushPendingCheckpoints(): Promise { + const ids = Array.from(this.sessions.keys()); + for (const id of ids) { + const session = this.sessions.get(id); + if (session?.autosaveTimer != null) { + this.clearAutosaveTimer(session); + await this.checkpoint(id); + } + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private isSessionValid(session: PooledSession, metadata: AcquireMetadata): boolean { + if (session.sessionType === 'collab') { + const incomingFingerprint = metadata.collaboration ? profileToFingerprint(metadata.collaboration) : undefined; + return session.fingerprint === incomingFingerprint && session.workingDocPath === metadata.workingDocPath; + } + + // Local: validate working doc path and metadata revision match + return session.workingDocPath === metadata.workingDocPath && session.metadataRevision === metadata.metadataRevision; + } + + private async openFreshSession(metadata: AcquireMetadata, io: CliIO): Promise { + const opened = + metadata.sessionType === 'collab' && metadata.collaboration + ? await this.openCollaborative(metadata.workingDocPath, io, metadata.collaboration, { + user: metadata.user, + }) + : await this.openLocal(metadata.workingDocPath, io, { user: metadata.user }); + + return { + opened, + sessionType: metadata.sessionType, + dirty: false, + leased: true, + workingDocPath: metadata.workingDocPath, + io, + metadataRevision: metadata.metadataRevision, + lastUsedAtMs: this.now(), + autosaveTimer: null, + collaboration: metadata.collaboration, + fingerprint: metadata.collaboration ? profileToFingerprint(metadata.collaboration) : undefined, + }; + } + + private createLease(sessionId: string, session: PooledSession): OpenedDocument { + return { + editor: session.opened.editor, + meta: session.opened.meta, + dispose: () => { + session.leased = false; + session.lastUsedAtMs = this.now(); + }, + }; + } + + /** Checkpoint without acquiring the session lock (caller must hold it). */ + private async checkpointUnsafe(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session?.dirty) return; + + if (session.sessionType === 'collab') { + await this.checkpointCollabSession(session); + } else { + await this.checkpointLocalSession(session); + } + + session.dirty = false; + this.clearAutosaveTimer(session); + } + + private async checkpointLocalSession(session: PooledSession): Promise { + await this.exportToPathFn(session.opened.editor, session.workingDocPath, true); + } + + private async checkpointCollabSession(session: PooledSession): Promise { + // Pool checkpoint only flushes editor state to the working doc file. + // Metadata writes (dirty flag, revision bump) happen through the context + // layer during save/close — not here. + await this.exportToPathFn(session.opened.editor, session.workingDocPath, true); + } + + private async destroySession(session: PooledSession): Promise { + this.clearAutosaveTimer(session); + session.opened.dispose(); + } + + // ------------------------------------------------------------------------- + // Autosave timer + // ------------------------------------------------------------------------- + + private resetAutosaveTimer(sessionId: string, session: PooledSession): void { + this.clearAutosaveTimer(session); + + session.autosaveTimer = this.createTimer(() => { + this.onAutosaveTimerFired(sessionId).catch(() => { + // Autosave is best-effort — swallow to avoid unhandled rejection. + }); + }, AUTOSAVE_DEBOUNCE_MS); + } + + private clearAutosaveTimer(session: PooledSession): void { + if (session.autosaveTimer != null) { + this.clearTimer(session.autosaveTimer); + session.autosaveTimer = null; + } + } + + private async onAutosaveTimerFired(sessionId: string): Promise { + await this.sessionLocks.withLock(sessionId, async () => { + const session = this.sessions.get(sessionId); + if (!session) return; + + session.autosaveTimer = null; + + if (session.leased) { + // In-flight invoke — reschedule instead of checkpointing + this.resetAutosaveTimer(sessionId, session); + return; + } + + await this.checkpointUnsafe(sessionId); + }); + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 4a071a711d..58f841e1a9 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -60,7 +60,7 @@ export type InvokeCommandResult = { export type InvokeCommandOptions = { ioOverrides?: Partial; executionMode?: ExecutionMode; - collabSessionPool?: CommandContext['collabSessionPool']; + sessionPool?: CommandContext['sessionPool']; stateDir?: string; }; @@ -208,7 +208,7 @@ async function executeParsedInvocation( parsed: ParsedInvocation, io: CliIO, executionMode: ExecutionMode, - collabSessionPool?: CommandContext['collabSessionPool'], + sessionPool?: CommandContext['sessionPool'], ): Promise<{ execution?: CommandExecution; helpText?: string }> { if (parsed.globals.help || parsed.rest.length === 0) { return { helpText: HELP }; @@ -221,7 +221,7 @@ async function executeParsedInvocation( timeoutMs: parsed.globals.timeoutMs, sessionId: parsed.globals.sessionId, executionMode, - collabSessionPool, + sessionPool, }; const execution = await executeWithTimeout(async () => { @@ -270,7 +270,7 @@ export async function invokeCommand(argv: string[], options: InvokeCommandOption parsedInvocation, io, options.executionMode ?? 'oneshot', - options.collabSessionPool, + options.sessionPool, ); return { parsed: parsedInvocation, output: commandOutput }; }); diff --git a/apps/cli/src/lib/document.ts b/apps/cli/src/lib/document.ts index 52295f8cfa..9051aa2e02 100644 --- a/apps/cli/src/lib/document.ts +++ b/apps/cli/src/lib/document.ts @@ -24,7 +24,7 @@ import { CliError } from './errors'; import { pathExists } from './guards'; import type { ContextMetadata } from './context'; import type { CliIO, DocumentSourceMeta, ExecutionMode, UserIdentity } from './types'; -import type { CollaborationSessionPool } from '../host/collab-session-pool'; +import type { SessionPool } from '../host/session-pool'; export type EditorWithDoc = Editor & { doc: DocumentApi; @@ -319,41 +319,41 @@ export async function openSessionDocument( io: CliIO, metadata: Pick< ContextMetadata, - 'contextId' | 'sessionType' | 'collaboration' | 'sourcePath' | 'workingDocPath' | 'user' + 'contextId' | 'sessionType' | 'collaboration' | 'sourcePath' | 'workingDocPath' | 'user' | 'revision' >, options: { sessionId?: string; executionMode?: ExecutionMode; - collabSessionPool?: CollaborationSessionPool; + sessionPool?: SessionPool; } = {}, ): Promise { - if (metadata.sessionType !== 'collab') { - return openDocument(doc, io, { user: metadata.user }); - } - - if (!metadata.collaboration) { - throw new CliError('COMMAND_FAILED', 'Session is marked as collaborative but has no collaboration profile.'); + const { executionMode, sessionPool, sessionId } = options; + + // Host mode: always go through pool (local AND collab) + if (executionMode === 'host' && sessionPool) { + const resolvedSessionId = sessionId ?? metadata.contextId; + return sessionPool.acquire( + resolvedSessionId, + { + sessionType: metadata.sessionType, + workingDocPath: metadata.workingDocPath ?? doc, + metadataRevision: metadata.revision, + user: metadata.user, + collaboration: metadata.collaboration, + }, + io, + ); } - if (options.executionMode === 'host' && options.collabSessionPool) { - const sessionId = options.sessionId ?? metadata.contextId; - if (!sessionId) { - throw new CliError('COMMAND_FAILED', 'Session id is required for host-mode collaboration operations.'); + // Oneshot mode: open fresh, caller is responsible for dispose + if (metadata.sessionType === 'collab') { + if (!metadata.collaboration) { + throw new CliError('COMMAND_FAILED', 'Session is marked as collaborative but has no collaboration profile.'); } - - const metadataForPool = { - contextId: sessionId, - sessionType: metadata.sessionType, - collaboration: metadata.collaboration, - sourcePath: metadata.sourcePath, - workingDocPath: metadata.workingDocPath, - user: metadata.user, - }; - - return options.collabSessionPool.acquire(sessionId, doc, metadataForPool, io); + return openCollaborativeDocument(doc, io, metadata.collaboration, { user: metadata.user }); } - return openCollaborativeDocument(doc, io, metadata.collaboration, { user: metadata.user }); + return openDocument(doc, io, { user: metadata.user }); } export async function getFileChecksum(path: string): Promise { diff --git a/apps/cli/src/lib/mutation-orchestrator.ts b/apps/cli/src/lib/mutation-orchestrator.ts index cc998a40ab..d300f7c6b5 100644 --- a/apps/cli/src/lib/mutation-orchestrator.ts +++ b/apps/cli/src/lib/mutation-orchestrator.ts @@ -5,8 +5,8 @@ * comments-mutation-shared.ts, lists-mutation-shared.ts, and inline * in operation-extra-invokers.ts with a single generic path. * - * The 3-branch session structure (stateless / session+collab / session+local) - * is preserved but unified into one function. + * Two branches: stateless (--doc) and session (unified local + collab, + * host + oneshot). */ import { COMMAND_CATALOG } from '@superdoc/document-api'; @@ -194,7 +194,7 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr } // ----------------------------------------------------------------------- - // Session paths (collab or local) + // Session path (unified: local + collab, host + oneshot) // ----------------------------------------------------------------------- return withActiveContext( context.io, @@ -202,64 +202,24 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr async ({ metadata, paths }) => { assertExpectedRevision(metadata, expectedRevision); - // --- Session + collab --- - if (metadata.sessionType === 'collab') { - const opened = await openSessionDocument(paths.workingDocPath, context.io, metadata, { - sessionId: context.sessionId ?? metadata.contextId, - executionMode: context.executionMode, - collabSessionPool: context.collabSessionPool, - }); + const isHostMode = context.executionMode === 'host' && context.sessionPool != null; - try { - const result = invokeOperation(opened.editor, operationId, input, invokeOptions); - const synced = await syncCollaborativeSessionSnapshot(context.io, metadata, paths, opened.editor); - const document: DocumentPayload = { - path: synced.updatedMetadata.sourcePath, - source: synced.updatedMetadata.source, - byteLength: synced.output.byteLength, - revision: synced.updatedMetadata.revision, - }; - - if (dryRun) { - return { - command: commandName, - data: { - ...buildEnvelopeData(operationId, document, result, { changeMode, dryRun: true }), - context: { dirty: synced.updatedMetadata.dirty, revision: synced.updatedMetadata.revision }, - output: outPath ? { path: outPath, skippedWrite: true } : undefined, - }, - pretty: `Revision ${synced.updatedMetadata.revision}: dry run`, - }; - } - - const externalOutput = await exportOptionalSessionOutput(opened.editor, outPath, force); - return { - command: commandName, - data: buildEnvelopeData(operationId, document, result, { - changeMode, - dryRun: false, - context: { dirty: synced.updatedMetadata.dirty, revision: synced.updatedMetadata.revision }, - output: externalOutput, - }), - pretty: buildPrettyOutput(operationId, document, result, externalOutput?.path), - }; - } finally { - opened.dispose(); - } - } + const opened = await openSessionDocument(paths.workingDocPath, context.io, metadata, { + sessionId: context.sessionId ?? metadata.contextId, + executionMode: context.executionMode, + sessionPool: context.sessionPool, + }); - // --- Session + local --- - const opened = await openDocument(paths.workingDocPath, context.io, { user: metadata.user }); try { const result = invokeOperation(opened.editor, operationId, input, invokeOptions); - const document: DocumentPayload = { - path: metadata.sourcePath, - source: metadata.source, - byteLength: opened.meta.byteLength, - revision: metadata.revision, - }; if (dryRun) { + const document: DocumentPayload = { + path: metadata.sourcePath, + source: metadata.source, + byteLength: opened.meta.byteLength, + revision: metadata.revision, + }; return { command: commandName, data: { @@ -271,30 +231,53 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr }; } - const workingOutput = await exportToPath(opened.editor, paths.workingDocPath, true); - const externalOutput = await exportOptionalSessionOutput(opened.editor, outPath, force); - const updatedMetadata = markContextUpdated(context.io, metadata, { - dirty: true, - revision: metadata.revision + 1, - }); - await writeContextMetadata(paths, updatedMetadata); + // Persist based on mode + let updatedMetadata: typeof metadata; + let byteLength: number; - const updatedDocument: DocumentPayload = { + if (isHostMode) { + // Host mode: mark dirty, let pool handle persistence + context.sessionPool!.markDirty(metadata.contextId); + updatedMetadata = markContextUpdated(context.io, metadata, { + dirty: true, + revision: metadata.revision + 1, + }); + await writeContextMetadata(paths, updatedMetadata); + context.sessionPool!.updateMetadataRevision(metadata.contextId, updatedMetadata.revision); + byteLength = opened.meta.byteLength; + } else if (metadata.sessionType === 'collab') { + // Oneshot collab: sync snapshot to disk + const synced = await syncCollaborativeSessionSnapshot(context.io, metadata, paths, opened.editor); + updatedMetadata = synced.updatedMetadata; + byteLength = synced.output.byteLength; + } else { + // Oneshot local: export to disk + const workingOutput = await exportToPath(opened.editor, paths.workingDocPath, true); + updatedMetadata = markContextUpdated(context.io, metadata, { + dirty: true, + revision: metadata.revision + 1, + }); + await writeContextMetadata(paths, updatedMetadata); + byteLength = workingOutput.byteLength; + } + + const externalOutput = await exportOptionalSessionOutput(opened.editor, outPath, force); + const document: DocumentPayload = { path: updatedMetadata.sourcePath, source: updatedMetadata.source, - byteLength: workingOutput.byteLength, + byteLength, revision: updatedMetadata.revision, }; return { command: commandName, - data: buildEnvelopeData(operationId, updatedDocument, result, { + data: buildEnvelopeData(operationId, document, result, { changeMode, dryRun: false, context: { dirty: updatedMetadata.dirty, revision: updatedMetadata.revision }, output: externalOutput, }), - pretty: buildPrettyOutput(operationId, updatedDocument, result, externalOutput?.path), + pretty: buildPrettyOutput(operationId, document, result, externalOutput?.path), }; } finally { opened.dispose(); diff --git a/apps/cli/src/lib/read-orchestrator.ts b/apps/cli/src/lib/read-orchestrator.ts index 233cfe613b..f25dd6f460 100644 --- a/apps/cli/src/lib/read-orchestrator.ts +++ b/apps/cli/src/lib/read-orchestrator.ts @@ -120,19 +120,25 @@ export async function executeReadOperation(request: DocOperationRequest): Promis } } + // ----------------------------------------------------------------------- + // Session path (unified: local + collab, host + oneshot) + // ----------------------------------------------------------------------- return withActiveContext( context.io, commandName, async ({ metadata, paths }) => { - if (metadata.sessionType === 'collab') { - const opened = await openSessionDocument(paths.workingDocPath, context.io, metadata, { - sessionId: context.sessionId ?? metadata.contextId, - executionMode: context.executionMode, - collabSessionPool: context.collabSessionPool, - }); - - try { - const result = invokeOperation(opened.editor, operationId, input); + const opened = await openSessionDocument(paths.workingDocPath, context.io, metadata, { + sessionId: context.sessionId ?? metadata.contextId, + executionMode: context.executionMode, + sessionPool: context.sessionPool, + }); + + try { + const result = invokeOperation(opened.editor, operationId, input); + + // For oneshot collab reads, sync snapshot to keep working.docx current + const isHostMode = context.executionMode === 'host' && context.sessionPool != null; + if (!isHostMode && metadata.sessionType === 'collab') { const synced = await syncCollaborativeSessionSnapshot(context.io, metadata, paths, opened.editor); const document: DocumentPayload = { path: synced.updatedMetadata.sourcePath, @@ -140,27 +146,19 @@ export async function executeReadOperation(request: DocOperationRequest): Promis byteLength: synced.output.byteLength, revision: synced.updatedMetadata.revision, }; - return { command: commandName, data: buildEnvelopeData(operationId, document, result, input), pretty: buildPrettyOutput(operationId, document, result), }; - } finally { - opened.dispose(); } - } - const opened = await openDocument(paths.workingDocPath, context.io, { user: metadata.user }); - try { - const result = invokeOperation(opened.editor, operationId, input); const document: DocumentPayload = { path: metadata.sourcePath, source: metadata.source, byteLength: opened.meta.byteLength, revision: metadata.revision, }; - return { command: commandName, data: buildEnvelopeData(operationId, document, result, input), diff --git a/apps/cli/src/lib/types.ts b/apps/cli/src/lib/types.ts index d4657a58a0..b1c794d905 100644 --- a/apps/cli/src/lib/types.ts +++ b/apps/cli/src/lib/types.ts @@ -22,7 +22,7 @@ import type { Selector as DocumentApiSelector, TextAddress as DocumentApiTextAddress, } from '@superdoc/document-api'; -import type { CollaborationSessionPool } from '../host/collab-session-pool'; +import type { SessionPool } from '../host/session-pool'; export type NodeKind = DocumentApiNodeKind; export type NodeType = DocumentApiNodeType; @@ -78,7 +78,7 @@ export interface CommandContext { timeoutMs?: number; sessionId?: string; executionMode?: ExecutionMode; - collabSessionPool?: CollaborationSessionPool; + sessionPool?: SessionPool; } export interface DocumentSourceMeta { diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/compiler-ref-targeting.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/compiler-ref-targeting.test.ts index 890651f7ce..3e656459c3 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/compiler-ref-targeting.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/compiler-ref-targeting.test.ts @@ -328,7 +328,7 @@ describe('compilePlan V3 ref resolution', () => { throw new Error('expected compilePlan to throw REVISION_MISMATCH'); }); - it('allows stale V3 ref revisions when ref-revision enforcement is disabled', () => { + it('always rejects stale V3 ref revisions (ref-revision enforcement is unconditional)', () => { mockedDeps.getBlockIndex.mockReturnValue({ candidates: [{ nodeId: 'p1', pos: 0, end: 12, node: {} }], }); @@ -344,22 +344,18 @@ describe('compilePlan V3 ref resolution', () => { const editor = makeEditor(); const steps: MutationStep[] = [ { - id: 'stale-ref-allowed', + id: 'stale-ref-rejected', op: 'text.delete', where: { by: 'ref', ref }, args: {}, }, ]; - const plan = compilePlan(editor, steps, { enforceRefRevision: false }); - expect(plan.mutationSteps).toHaveLength(1); - expect(plan.mutationSteps[0].targets).toHaveLength(1); - const target = plan.mutationSteps[0].targets[0]; - expect(target.kind).toBe('range'); - if (target.kind === 'range') { - expect(target.blockId).toBe('p1'); - expect(target.from).toBe(0); - expect(target.to).toBe(5); + try { + compilePlan(editor, steps); + expect.unreachable('Expected REVISION_MISMATCH'); + } catch (error) { + expect((error as any).code).toBe('REVISION_MISMATCH'); } }); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts b/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts index ae38190242..27132021c8 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts @@ -45,14 +45,6 @@ export interface CompiledPlan { compiledRevision: string; } -interface CompilePlanOptions { - /** - * Enforce V3 text-ref revision checks during ref resolution. - * When false, stale ref revisions are tolerated and resolution is best-effort. - */ - enforceRefRevision?: boolean; -} - function isAssertStep(step: MutationStep): step is AssertStep { return step.op === 'assert'; } @@ -588,16 +580,9 @@ function decodeTextRefPayload(encoded: string, stepId: string): unknown { * Single-segment refs produce a CompiledRangeTarget; multi-segment refs * produce a CompiledSpanTarget. */ -function resolveV3TextRef( - editor: Editor, - index: BlockIndex, - step: MutationStep, - refData: TextRefV3, - options?: CompilePlanOptions, -): CompiledTarget[] { +function resolveV3TextRef(editor: Editor, index: BlockIndex, step: MutationStep, refData: TextRefV3): CompiledTarget[] { const currentRevision = getRevision(editor); - const enforceRefRevision = options?.enforceRefRevision ?? true; - if (enforceRefRevision && refData.rev !== currentRevision) { + if (refData.rev !== currentRevision) { throw planError( 'REVISION_MISMATCH', `Text ref is ephemeral and revision-scoped. Re-run query.match to obtain a fresh handle.ref for revision ${currentRevision}.`, @@ -644,13 +629,7 @@ function resolveV3TextRef( return [buildSpanTarget(editor, index, step, segments, refData.matchId)]; } -function resolveTextRef( - editor: Editor, - index: BlockIndex, - step: MutationStep, - ref: string, - options?: CompilePlanOptions, -): CompiledTarget[] { +function resolveTextRef(editor: Editor, index: BlockIndex, step: MutationStep, ref: string): CompiledTarget[] { const encoded = ref.slice(5); // strip 'text:' prefix const payload = decodeTextRefPayload(encoded, step.id); @@ -658,7 +637,7 @@ function resolveTextRef( throw planError('INVALID_INPUT', 'only V3 text refs are supported', step.id); } - return resolveV3TextRef(editor, index, step, payload, options); + return resolveV3TextRef(editor, index, step, payload); } function resolveBlockRef(editor: Editor, index: BlockIndex, step: MutationStep, ref: string): CompiledTarget[] { @@ -682,13 +661,7 @@ function resolveBlockRef(editor: Editor, index: BlockIndex, step: MutationStep, // Ref handler registry — dispatches by prefix (C4) // --------------------------------------------------------------------------- -type RefHandler = ( - editor: Editor, - index: BlockIndex, - step: MutationStep, - ref: string, - options?: CompilePlanOptions, -) => CompiledTarget[]; +type RefHandler = (editor: Editor, index: BlockIndex, step: MutationStep, ref: string) => CompiledTarget[]; /** * Prefix-based ref handler registry. @@ -721,42 +694,25 @@ const REF_HANDLERS: Array<{ prefix: string; handler: RefHandler }> = [ { prefix: '', handler: resolveBlockRef }, ]; -function dispatchRefHandler( - editor: Editor, - index: BlockIndex, - step: MutationStep, - ref: string, - options?: CompilePlanOptions, -): CompiledTarget[] { +function dispatchRefHandler(editor: Editor, index: BlockIndex, step: MutationStep, ref: string): CompiledTarget[] { for (const entry of REF_HANDLERS) { if (entry.prefix === '' || ref.startsWith(entry.prefix)) { - return entry.handler(editor, index, step, ref, options); + return entry.handler(editor, index, step, ref); } } // Unreachable — the default handler (empty prefix) always matches return resolveBlockRef(editor, index, step, ref); } -function resolveRefTargets( - editor: Editor, - index: BlockIndex, - step: MutationStep, - where: RefWhere, - options?: CompilePlanOptions, -): CompiledTarget[] { - return dispatchRefHandler(editor, index, step, where.ref, options); +function resolveRefTargets(editor: Editor, index: BlockIndex, step: MutationStep, where: RefWhere): CompiledTarget[] { + return dispatchRefHandler(editor, index, step, where.ref); } // --------------------------------------------------------------------------- // Step target resolution // --------------------------------------------------------------------------- -function resolveStepTargets( - editor: Editor, - index: BlockIndex, - step: MutationStep, - options?: CompilePlanOptions, -): CompiledTarget[] { +function resolveStepTargets(editor: Editor, index: BlockIndex, step: MutationStep): CompiledTarget[] { const where = step.where; const refWhere = isRefWhere(where) ? where : undefined; const selectWhere = isSelectWhere(where) ? where : undefined; @@ -764,7 +720,7 @@ function resolveStepTargets( let targets: CompiledTarget[]; if (refWhere) { - targets = resolveRefTargets(editor, index, step, refWhere, options); + targets = resolveRefTargets(editor, index, step, refWhere); } else if (selectWhere) { const resolved = resolveTextSelector(editor, index, selectWhere.select, selectWhere.within, step.id); targets = resolved.addresses.map((addr) => { @@ -1090,7 +1046,7 @@ function assertNoDuplicateBlockIds(index: BlockIndex): void { } } -export function compilePlan(editor: Editor, steps: MutationStep[], options?: CompilePlanOptions): CompiledPlan { +export function compilePlan(editor: Editor, steps: MutationStep[]): CompiledPlan { // D8: plan step limit if (steps.length > MAX_PLAN_STEPS) { throw planError('INVALID_INPUT', `plan contains ${steps.length} steps, maximum is ${MAX_PLAN_STEPS}`); @@ -1144,7 +1100,7 @@ export function compilePlan(editor: Editor, steps: MutationStep[], options?: Com validateCreateStepPosition(step); } - const targets = resolveStepTargets(editor, index, step, options); + const targets = resolveStepTargets(editor, index, step); // Validate insertion context for create ops (B0 invariant 5) if (isCreateOp(step.op) && targets.length > 0) { diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts index 6faec83830..5848d9882c 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts @@ -1446,9 +1446,7 @@ export function executePlan(editor: Editor, input: MutationsApplyInput): PlanRec throw planError('INVALID_INPUT', 'plan must contain at least one step'); } - const compiled = compilePlan(editor, input.steps, { - enforceRefRevision: input.expectedRevision !== undefined, - }); + const compiled = compilePlan(editor, input.steps); return executeCompiledPlan(editor, compiled, { changeMode: input.changeMode ?? 'direct', diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts b/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts index 09963b2fa7..75073c184c 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts @@ -35,9 +35,7 @@ export function previewPlan(editor: Editor, input: MutationsPreviewInput): Mutat try { // Phase 1: Compile — resolve selectors against pre-mutation snapshot - const compiled = compilePlan(editor, input.steps, { - enforceRefRevision: input.expectedRevision !== undefined, - }); + const compiled = compilePlan(editor, input.steps); evaluatedRevision = compiled.compiledRevision; currentPhase = 'execute';