From 262f57d84a67acae05eb372138bc1efe6c164426 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 09:29:27 +0000 Subject: [PATCH 01/42] Introduce state sync engine seam and LiveStore shadow mirror Co-authored-by: Julius Marminge --- apps/server/package.json | 2 + apps/server/src/index.ts | 30 +++ apps/server/src/livestore/liveStoreEngine.ts | 133 +++++++++++ apps/server/src/livestore/materializers.ts | 16 ++ apps/server/src/livestore/schema.ts | 25 +++ apps/server/src/stateSyncEngine.ts | 53 +++++ apps/server/src/stateSyncEngineLegacy.ts | 117 ++++++++++ apps/server/src/stateSyncEngineShadow.test.ts | 118 ++++++++++ apps/server/src/stateSyncEngineShadow.ts | 149 +++++++++++++ apps/server/src/wsServer.ts | 48 ++-- bun.lock | 206 ++++++++++++++++++ 11 files changed, 878 insertions(+), 19 deletions(-) create mode 100644 apps/server/src/livestore/liveStoreEngine.ts create mode 100644 apps/server/src/livestore/materializers.ts create mode 100644 apps/server/src/livestore/schema.ts create mode 100644 apps/server/src/stateSyncEngine.ts create mode 100644 apps/server/src/stateSyncEngineLegacy.ts create mode 100644 apps/server/src/stateSyncEngineShadow.test.ts create mode 100644 apps/server/src/stateSyncEngineShadow.ts diff --git a/apps/server/package.json b/apps/server/package.json index 59f41aa67800..dac66a4388a9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,6 +17,8 @@ "test": "vitest run" }, "dependencies": { + "@livestore/adapter-node": "^0.3.1", + "@livestore/livestore": "^0.3.1", "@pierre/diffs": "^1.1.0-beta.16", "node-pty": "^1.1.0", "open": "^10.1.0", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index a8c8e5371bcc..6102a28c54d0 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -6,7 +6,10 @@ import { fileURLToPath } from "node:url"; import { fixPath } from "./fixPath"; import { createLogger } from "./logger"; +import { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; import { PersistenceService } from "./persistenceService"; +import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; +import { ShadowStateSyncEngine } from "./stateSyncEngineShadow"; import { createServer } from "./wsServer"; fixPath(); @@ -16,6 +19,7 @@ const cwd = process.cwd(); const logger = createLogger("server"); type RuntimeMode = "web" | "desktop"; +type SyncEngineMode = "legacy" | "shadow"; function parseBooleanEnv(value: string | undefined): boolean | undefined { if (value === undefined) return undefined; @@ -34,6 +38,19 @@ function parsePort(value: string | undefined): number | undefined { return parsed; } +function resolveSyncEngineMode(raw: string | undefined): SyncEngineMode { + if (!raw || raw.trim().length === 0) { + return "legacy"; + } + const normalized = raw.trim().toLowerCase(); + if (normalized === "legacy" || normalized === "shadow") { + return normalized; + } + throw new Error( + `Invalid T3CODE_SYNC_ENGINE_MODE: ${raw}. Expected "legacy" or "shadow".`, + ); +} + function expandHomePath(input: string): string { if (input === "~") return os.homedir(); if (input.startsWith("~/")) { @@ -110,10 +127,21 @@ async function main() { requestedPort ?? (mode === "desktop" ? DEFAULT_PORT : await findAvailablePort(DEFAULT_PORT)); const legacyStateDir = resolveStateDir(process.env.T3CODE_STATE_DIR); const stateDbPath = resolveStateDbPath(process.env.T3CODE_STATE_DB_PATH); + const syncEngineMode = resolveSyncEngineMode(process.env.T3CODE_SYNC_ENGINE_MODE); const persistenceService = new PersistenceService({ dbPath: stateDbPath, legacyProjectsJsonPath: path.join(legacyStateDir, "projects.json"), }); + const legacyStateSyncEngine = new LegacyStateSyncEngine({ persistenceService }); + const stateSyncEngine = + syncEngineMode === "shadow" + ? new ShadowStateSyncEngine({ + delegate: legacyStateSyncEngine, + mirror: new LiveStoreStateMirror({ + storeId: `t3-shadow-${mode}`, + }), + }) + : legacyStateSyncEngine; const devUrl = process.env.VITE_DEV_SERVER_URL; const noBrowser = parseBooleanEnv(process.env.T3CODE_NO_BROWSER) ?? mode === "desktop"; const authToken = process.env.T3CODE_AUTH_TOKEN; @@ -132,6 +160,7 @@ async function main() { staticDir, devUrl, persistenceService, + stateSyncEngine, authToken, }); await server.start(); @@ -143,6 +172,7 @@ async function main() { mode, stateDbPath, legacyStateDir, + syncEngineMode, authEnabled: Boolean(authToken), }); diff --git a/apps/server/src/livestore/liveStoreEngine.ts b/apps/server/src/livestore/liveStoreEngine.ts new file mode 100644 index 000000000000..3d72e3c9b999 --- /dev/null +++ b/apps/server/src/livestore/liveStoreEngine.ts @@ -0,0 +1,133 @@ +import { makeAdapter } from "@livestore/adapter-node"; +import { createStorePromise } from "@livestore/livestore"; +import type { StateEvent } from "@t3tools/contracts"; +import { createLogger } from "../logger"; +import type { StateEventMirror } from "../stateSyncEngineShadow"; +import { liveStoreShadowSchema } from "./materializers"; +import { liveStoreShadowEvents } from "./schema"; + +export interface LiveStoreStateMirrorOptions { + enabled?: boolean; + storeId?: string; +} + +interface LiveStoreShadowStore { + commit: (event: unknown) => unknown; + shutdown?: () => unknown; + shutdownPromise?: () => Promise; +} + +export class LiveStoreStateMirror implements StateEventMirror { + private readonly enabled: boolean; + private readonly storeId: string; + private readonly logger = createLogger("livestore-shadow"); + private store: LiveStoreShadowStore | null = null; + private storePromise: Promise | null = null; + private lastMirroredSeq = 0; + private disposed = false; + + constructor(options: LiveStoreStateMirrorOptions = {}) { + this.enabled = options.enabled ?? true; + this.storeId = options.storeId ?? "t3-shadow-sync"; + } + + async mirrorStateEvent(event: StateEvent): Promise { + if (!this.enabled || this.disposed) { + return; + } + if (event.seq <= this.lastMirroredSeq) { + return; + } + + const store = await this.getStore(); + if (!store) { + return; + } + + try { + store.commit( + liveStoreShadowEvents.stateEventMirrored({ + seq: event.seq, + eventType: event.eventType, + entityId: event.entityId, + payloadJson: JSON.stringify(event.payload), + createdAt: event.createdAt, + }, "shadow"), + ); + this.lastMirroredSeq = event.seq; + } catch (error) { + this.logger.warn("failed to commit mirrored state event", { + error, + seq: event.seq, + eventType: event.eventType, + }); + } + } + + async dispose(): Promise { + if (this.disposed) { + return; + } + this.disposed = true; + const store = this.store; + this.store = null; + this.storePromise = null; + this.lastMirroredSeq = 0; + if (!store) { + return; + } + try { + if (typeof store.shutdownPromise === "function") { + await store.shutdownPromise(); + } else if (typeof store.shutdown === "function") { + const maybeResult = store.shutdown(); + if (maybeResult && typeof (maybeResult as PromiseLike).then === "function") { + await maybeResult; + } + } + } catch (error) { + this.logger.warn("failed to shutdown livestore shadow store", { error }); + } + } + + private async getStore(): Promise { + if (!this.enabled || this.disposed) { + return null; + } + + if (!this.storePromise) { + const adapter = makeAdapter({ + // Shadow mode is intended to validate event parity first. + // Persisted storage can be enabled later during cutover. + storage: { type: "in-memory" }, + }); + this.storePromise = createStorePromise({ + adapter, + schema: liveStoreShadowSchema, + storeId: this.storeId, + }) + .then((store) => { + const castStore = store as unknown as LiveStoreShadowStore; + this.store = castStore; + this.logger.info("initialized livestore shadow store", { + storeId: this.storeId, + }); + return castStore; + }) + .catch((error) => { + this.logger.warn("failed to initialize livestore shadow store", { + error, + storeId: this.storeId, + }); + this.storePromise = null; + return Promise.reject(error); + }); + } + + try { + return await this.storePromise; + } catch { + return null; + } + } +} diff --git a/apps/server/src/livestore/materializers.ts b/apps/server/src/livestore/materializers.ts new file mode 100644 index 000000000000..b58447594198 --- /dev/null +++ b/apps/server/src/livestore/materializers.ts @@ -0,0 +1,16 @@ +import { makeSchema, State } from "@livestore/livestore"; +import { liveStoreShadowEvents, shadowMirrorStatus } from "./schema"; + +export const liveStoreShadowMaterializers = {}; + +export const liveStoreShadowState = State.SQLite.makeState({ + tables: { + shadowMirrorStatus, + }, + materializers: liveStoreShadowMaterializers, +}); + +export const liveStoreShadowSchema = makeSchema({ + events: liveStoreShadowEvents, + state: liveStoreShadowState, +}); diff --git a/apps/server/src/livestore/schema.ts b/apps/server/src/livestore/schema.ts new file mode 100644 index 000000000000..6583825a630a --- /dev/null +++ b/apps/server/src/livestore/schema.ts @@ -0,0 +1,25 @@ +import { Schema, State } from "@livestore/livestore"; + +export const shadowMirrorStatus = State.SQLite.clientDocument({ + name: "shadowMirrorStatus", + schema: Schema.Struct({ + seq: Schema.Number, + eventType: Schema.String, + entityId: Schema.String, + payloadJson: Schema.String, + createdAt: Schema.String, + }), + default: { + value: { + seq: 0, + eventType: "", + entityId: "", + payloadJson: "{}", + createdAt: new Date(0).toISOString(), + }, + }, +}); + +export const liveStoreShadowEvents = { + stateEventMirrored: shadowMirrorStatus.set, +} as const; diff --git a/apps/server/src/stateSyncEngine.ts b/apps/server/src/stateSyncEngine.ts new file mode 100644 index 000000000000..89d3db865361 --- /dev/null +++ b/apps/server/src/stateSyncEngine.ts @@ -0,0 +1,53 @@ +import type { + AppSettings, + AppSettingsUpdateInput, + ProjectAddInput, + ProjectAddResult, + ProjectListResult, + ProjectRemoveInput, + ProjectUpdateScriptsInput, + ProjectUpdateScriptsResult, + StateBootstrapResult, + StateCatchUpInput, + StateCatchUpResult, + StateEvent, + StateListMessagesInput, + StateListMessagesResult, + ThreadsCreateInput, + ThreadsDeleteInput, + ThreadsMarkVisitedInput, + ThreadsUpdateBranchInput, + ThreadsUpdateModelInput, + ThreadsUpdateResult, + ThreadsUpdateTerminalStateInput, + ThreadsUpdateTitleInput, +} from "@t3tools/contracts"; + +export interface ApplyCheckpointRevertInput { + sessionId: string; + runtimeThreadId: string; + turnCount: number; + messageCount: number; +} + +export interface StateSyncEngine { + onStateEvent(listener: (event: StateEvent) => void): () => void; + loadSnapshot(): StateBootstrapResult; + listMessages(raw: StateListMessagesInput): StateListMessagesResult; + catchUp(raw: StateCatchUpInput): StateCatchUpResult; + getAppSettings(): AppSettings; + updateAppSettings(raw: AppSettingsUpdateInput): AppSettings; + createThread(raw: ThreadsCreateInput): ThreadsUpdateResult; + updateThreadTerminalState(raw: ThreadsUpdateTerminalStateInput): ThreadsUpdateResult; + updateThreadModel(raw: ThreadsUpdateModelInput): ThreadsUpdateResult; + updateThreadTitle(raw: ThreadsUpdateTitleInput): ThreadsUpdateResult; + updateThreadBranch(raw: ThreadsUpdateBranchInput): ThreadsUpdateResult; + markThreadVisited(raw: ThreadsMarkVisitedInput): ThreadsUpdateResult; + deleteThread(raw: ThreadsDeleteInput): void; + listProjects(): ProjectListResult; + addProject(raw: ProjectAddInput): ProjectAddResult; + removeProject(raw: ProjectRemoveInput): void; + updateProjectScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult; + applyCheckpointRevert(input: ApplyCheckpointRevertInput): void; + close(): void; +} diff --git a/apps/server/src/stateSyncEngineLegacy.ts b/apps/server/src/stateSyncEngineLegacy.ts new file mode 100644 index 000000000000..26c42b54e3de --- /dev/null +++ b/apps/server/src/stateSyncEngineLegacy.ts @@ -0,0 +1,117 @@ +import type { + AppSettings, + AppSettingsUpdateInput, + ProjectAddInput, + ProjectAddResult, + ProjectListResult, + ProjectRemoveInput, + ProjectUpdateScriptsInput, + ProjectUpdateScriptsResult, + StateBootstrapResult, + StateCatchUpInput, + StateCatchUpResult, + StateEvent, + StateListMessagesInput, + StateListMessagesResult, + ThreadsCreateInput, + ThreadsDeleteInput, + ThreadsMarkVisitedInput, + ThreadsUpdateBranchInput, + ThreadsUpdateModelInput, + ThreadsUpdateResult, + ThreadsUpdateTerminalStateInput, + ThreadsUpdateTitleInput, +} from "@t3tools/contracts"; +import type { PersistenceService } from "./persistenceService"; +import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; + +export interface LegacyStateSyncEngineOptions { + persistenceService: PersistenceService; +} + +export class LegacyStateSyncEngine implements StateSyncEngine { + private readonly persistenceService: PersistenceService; + + constructor(options: LegacyStateSyncEngineOptions) { + this.persistenceService = options.persistenceService; + } + + onStateEvent(listener: (event: StateEvent) => void): () => void { + this.persistenceService.on("stateEvent", listener); + return () => { + this.persistenceService.off("stateEvent", listener); + }; + } + + loadSnapshot(): StateBootstrapResult { + return this.persistenceService.loadSnapshot(); + } + + listMessages(raw: StateListMessagesInput): StateListMessagesResult { + return this.persistenceService.listMessages(raw); + } + + catchUp(raw: StateCatchUpInput): StateCatchUpResult { + return this.persistenceService.catchUp(raw); + } + + getAppSettings(): AppSettings { + return this.persistenceService.getAppSettings(); + } + + updateAppSettings(raw: AppSettingsUpdateInput): AppSettings { + return this.persistenceService.updateAppSettings(raw); + } + + createThread(raw: ThreadsCreateInput): ThreadsUpdateResult { + return this.persistenceService.createThread(raw); + } + + updateThreadTerminalState(raw: ThreadsUpdateTerminalStateInput): ThreadsUpdateResult { + return this.persistenceService.updateThreadTerminalState(raw); + } + + updateThreadModel(raw: ThreadsUpdateModelInput): ThreadsUpdateResult { + return this.persistenceService.updateThreadModel(raw); + } + + updateThreadTitle(raw: ThreadsUpdateTitleInput): ThreadsUpdateResult { + return this.persistenceService.updateThreadTitle(raw); + } + + updateThreadBranch(raw: ThreadsUpdateBranchInput): ThreadsUpdateResult { + return this.persistenceService.updateThreadBranch(raw); + } + + markThreadVisited(raw: ThreadsMarkVisitedInput): ThreadsUpdateResult { + return this.persistenceService.markThreadVisited(raw); + } + + deleteThread(raw: ThreadsDeleteInput): void { + this.persistenceService.deleteThread(raw); + } + + listProjects(): ProjectListResult { + return this.persistenceService.listProjects(); + } + + addProject(raw: ProjectAddInput): ProjectAddResult { + return this.persistenceService.addProject(raw); + } + + removeProject(raw: ProjectRemoveInput): void { + this.persistenceService.removeProject(raw); + } + + updateProjectScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult { + return this.persistenceService.updateProjectScripts(raw); + } + + applyCheckpointRevert(input: ApplyCheckpointRevertInput): void { + this.persistenceService.applyCheckpointRevert(input); + } + + close(): void { + // Persistence service lifecycle is owned by server bootstrap. + } +} diff --git a/apps/server/src/stateSyncEngineShadow.test.ts b/apps/server/src/stateSyncEngineShadow.test.ts new file mode 100644 index 000000000000..a0f1e68d4318 --- /dev/null +++ b/apps/server/src/stateSyncEngineShadow.test.ts @@ -0,0 +1,118 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; +import type { StateEvent } from "@t3tools/contracts"; +import { PersistenceService } from "./persistenceService"; +import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; +import type { StateEventMirror } from "./stateSyncEngineShadow"; +import { ShadowStateSyncEngine } from "./stateSyncEngineShadow"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +class CapturingMirror implements StateEventMirror { + readonly events: StateEvent[] = []; + disposeCalls = 0; + + async mirrorStateEvent(event: StateEvent): Promise { + this.events.push(event); + } + + async dispose(): Promise { + this.disposeCalls += 1; + } +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("ShadowStateSyncEngine", () => { + it("delegates durable APIs and mirrors emitted state events", async () => { + const stateDir = makeTempDir("t3code-shadow-state-"); + const projectDir = makeTempDir("t3code-shadow-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const mirror = new CapturingMirror(); + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + const observedEvents: StateEvent[] = []; + const unsubscribe = shadow.onStateEvent((event) => { + observedEvents.push(event); + }); + + const project = shadow.addProject({ cwd: projectDir }).project; + const thread = shadow.createThread({ + projectId: project.id, + title: "Shadow thread", + }).thread; + shadow.updateThreadTitle({ + threadId: thread.id, + title: "Shadow thread updated", + }); + + await Promise.resolve(); + + const snapshot = shadow.loadSnapshot(); + expect(snapshot.projects).toHaveLength(1); + expect(snapshot.threads).toHaveLength(1); + expect(snapshot.threads[0]?.title).toBe("Shadow thread updated"); + + const catchUp = shadow.catchUp({ afterSeq: 0 }); + expect(catchUp.events.length).toBeGreaterThan(0); + expect(catchUp.events.map((event) => event.eventType)).toEqual( + expect.arrayContaining(["project.upsert", "thread.upsert"]), + ); + + expect(mirror.events.map((event) => event.seq)).toEqual( + observedEvents.map((event) => event.seq), + ); + + unsubscribe(); + } finally { + shadow.close(); + service.close(); + } + }); + + it("stops mirroring and disposes once on close", async () => { + const stateDir = makeTempDir("t3code-shadow-close-state-"); + const projectDir = makeTempDir("t3code-shadow-close-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const mirror = new CapturingMirror(); + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + shadow.close(); + shadow.close(); + + legacy.addProject({ cwd: projectDir }); + await Promise.resolve(); + expect(mirror.events).toHaveLength(0); + expect(mirror.disposeCalls).toBe(1); + } finally { + service.close(); + } + }); +}); diff --git a/apps/server/src/stateSyncEngineShadow.ts b/apps/server/src/stateSyncEngineShadow.ts new file mode 100644 index 000000000000..ca1a2085018d --- /dev/null +++ b/apps/server/src/stateSyncEngineShadow.ts @@ -0,0 +1,149 @@ +import { EventEmitter } from "node:events"; +import type { + AppSettings, + AppSettingsUpdateInput, + ProjectAddInput, + ProjectAddResult, + ProjectListResult, + ProjectRemoveInput, + ProjectUpdateScriptsInput, + ProjectUpdateScriptsResult, + StateBootstrapResult, + StateCatchUpInput, + StateCatchUpResult, + StateEvent, + StateListMessagesInput, + StateListMessagesResult, + ThreadsCreateInput, + ThreadsDeleteInput, + ThreadsMarkVisitedInput, + ThreadsUpdateBranchInput, + ThreadsUpdateModelInput, + ThreadsUpdateResult, + ThreadsUpdateTerminalStateInput, + ThreadsUpdateTitleInput, +} from "@t3tools/contracts"; +import { createLogger } from "./logger"; +import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; + +interface StateSyncEngineShadowEvents { + stateEvent: [event: StateEvent]; +} + +export interface StateEventMirror { + mirrorStateEvent(event: StateEvent): Promise; + dispose(): Promise | void; +} + +export interface ShadowStateSyncEngineOptions { + delegate: StateSyncEngine; + mirror: StateEventMirror; +} + +export class ShadowStateSyncEngine + extends EventEmitter + implements StateSyncEngine +{ + private readonly delegate: StateSyncEngine; + private readonly mirror: StateEventMirror; + private readonly logger = createLogger("shadow-sync-engine"); + private readonly unsubscribeDelegate: () => void; + private closed = false; + + constructor(options: ShadowStateSyncEngineOptions) { + super(); + this.delegate = options.delegate; + this.mirror = options.mirror; + this.unsubscribeDelegate = this.delegate.onStateEvent((event) => { + this.emit("stateEvent", event); + void this.mirror.mirrorStateEvent(event).catch((error) => { + this.logger.warn("livestore shadow mirror failed", { error, seq: event.seq }); + }); + }); + } + + onStateEvent(listener: (event: StateEvent) => void): () => void { + this.on("stateEvent", listener); + return () => { + this.off("stateEvent", listener); + }; + } + + loadSnapshot(): StateBootstrapResult { + return this.delegate.loadSnapshot(); + } + + listMessages(raw: StateListMessagesInput): StateListMessagesResult { + return this.delegate.listMessages(raw); + } + + catchUp(raw: StateCatchUpInput): StateCatchUpResult { + return this.delegate.catchUp(raw); + } + + getAppSettings(): AppSettings { + return this.delegate.getAppSettings(); + } + + updateAppSettings(raw: AppSettingsUpdateInput): AppSettings { + return this.delegate.updateAppSettings(raw); + } + + createThread(raw: ThreadsCreateInput): ThreadsUpdateResult { + return this.delegate.createThread(raw); + } + + updateThreadTerminalState(raw: ThreadsUpdateTerminalStateInput): ThreadsUpdateResult { + return this.delegate.updateThreadTerminalState(raw); + } + + updateThreadModel(raw: ThreadsUpdateModelInput): ThreadsUpdateResult { + return this.delegate.updateThreadModel(raw); + } + + updateThreadTitle(raw: ThreadsUpdateTitleInput): ThreadsUpdateResult { + return this.delegate.updateThreadTitle(raw); + } + + updateThreadBranch(raw: ThreadsUpdateBranchInput): ThreadsUpdateResult { + return this.delegate.updateThreadBranch(raw); + } + + markThreadVisited(raw: ThreadsMarkVisitedInput): ThreadsUpdateResult { + return this.delegate.markThreadVisited(raw); + } + + deleteThread(raw: ThreadsDeleteInput): void { + this.delegate.deleteThread(raw); + } + + listProjects(): ProjectListResult { + return this.delegate.listProjects(); + } + + addProject(raw: ProjectAddInput): ProjectAddResult { + return this.delegate.addProject(raw); + } + + removeProject(raw: ProjectRemoveInput): void { + this.delegate.removeProject(raw); + } + + updateProjectScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult { + return this.delegate.updateProjectScripts(raw); + } + + applyCheckpointRevert(input: ApplyCheckpointRevertInput): void { + this.delegate.applyCheckpointRevert(input); + } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + this.unsubscribeDelegate(); + this.removeAllListeners(); + void this.mirror.dispose(); + } +} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 9e92c76e69bc..8d223926ef6e 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -33,6 +33,8 @@ import { TerminalManager } from "./terminalManager"; import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; import { PersistenceService } from "./persistenceService"; +import type { StateSyncEngine } from "./stateSyncEngine"; +import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -57,6 +59,7 @@ export interface ServerOptions { devUrl?: string | undefined; logWebSocketEvents?: boolean | undefined; persistenceService?: PersistenceService | undefined; + stateSyncEngine?: StateSyncEngine | undefined; stateDbPath?: string | undefined; gitManager?: GitManager | undefined; terminalManager?: TerminalManager | undefined; @@ -80,6 +83,7 @@ export function createServer(options: ServerOptions) { devUrl, logWebSocketEvents: explicitLogWsEvents, persistenceService: providedPersistenceService, + stateSyncEngine: providedStateSyncEngine, stateDbPath, gitManager: providedGitManager, terminalManager: providedTerminalManager, @@ -91,6 +95,9 @@ export function createServer(options: ServerOptions) { dbPath: stateDbPath ?? path.join(os.homedir(), ".t3", "state.sqlite"), }); const ownsPersistenceService = providedPersistenceService === undefined; + const stateSyncEngine = + providedStateSyncEngine ?? new LegacyStateSyncEngine({ persistenceService }); + const ownsStateSyncEngine = providedStateSyncEngine === undefined; const providerManager = new ProviderManager({ persistenceService }); const terminalManager = providedTerminalManager ?? new TerminalManager(); const gitManager = providedGitManager ?? new GitManager(); @@ -143,7 +150,7 @@ export function createServer(options: ServerOptions) { } logOutgoingPush(push, recipients); }; - persistenceService.on("stateEvent", onStateEvent); + const unsubscribeStateEvents = stateSyncEngine.onStateEvent(onStateEvent); const onTerminalEvent = (event: TerminalEvent) => { const push: WsPush = { @@ -348,7 +355,7 @@ export function createServer(options: ServerOptions) { }; const result = await providerManager.revertToCheckpoint(params as never); try { - persistenceService.applyCheckpointRevert({ + stateSyncEngine.applyCheckpointRevert({ sessionId: params.sessionId, runtimeThreadId: result.threadId, turnCount: result.turnCount, @@ -365,22 +372,22 @@ export function createServer(options: ServerOptions) { } case WS_METHODS.stateBootstrap: - return persistenceService.loadSnapshot(); + return stateSyncEngine.loadSnapshot(); case WS_METHODS.stateListMessages: - return persistenceService.listMessages(request.params as never); + return stateSyncEngine.listMessages(request.params as never); case WS_METHODS.stateCatchUp: - return persistenceService.catchUp(request.params as never); + return stateSyncEngine.catchUp(request.params as never); case WS_METHODS.appSettingsGet: - return persistenceService.getAppSettings(); + return stateSyncEngine.getAppSettings(); case WS_METHODS.appSettingsUpdate: - return persistenceService.updateAppSettings((request.params ?? {}) as never); + return stateSyncEngine.updateAppSettings((request.params ?? {}) as never); case WS_METHODS.threadsCreate: - return persistenceService.createThread(request.params as never); + return stateSyncEngine.createThread(request.params as never); case "threads.update": throw new Error( @@ -388,38 +395,38 @@ export function createServer(options: ServerOptions) { ); case WS_METHODS.threadsUpdateTerminalState: - return persistenceService.updateThreadTerminalState(request.params as never); + return stateSyncEngine.updateThreadTerminalState(request.params as never); case WS_METHODS.threadsDelete: - persistenceService.deleteThread(request.params as never); + stateSyncEngine.deleteThread(request.params as never); return undefined; case WS_METHODS.threadsMarkVisited: - return persistenceService.markThreadVisited(request.params as never); + return stateSyncEngine.markThreadVisited(request.params as never); case WS_METHODS.threadsUpdateModel: - return persistenceService.updateThreadModel(request.params as never); + return stateSyncEngine.updateThreadModel(request.params as never); case WS_METHODS.threadsUpdateTitle: - return persistenceService.updateThreadTitle(request.params as never); + return stateSyncEngine.updateThreadTitle(request.params as never); case WS_METHODS.threadsUpdateBranch: - return persistenceService.updateThreadBranch(request.params as never); + return stateSyncEngine.updateThreadBranch(request.params as never); case WS_METHODS.projectsList: - return persistenceService.listProjects(); + return stateSyncEngine.listProjects(); case WS_METHODS.projectsAdd: - return persistenceService.addProject(request.params as never); + return stateSyncEngine.addProject(request.params as never); case WS_METHODS.projectsRemove: - persistenceService.removeProject(request.params as never); + stateSyncEngine.removeProject(request.params as never); return undefined; case WS_METHODS.projectsSearchEntries: return searchWorkspaceEntries(request.params as never); case WS_METHODS.projectsUpdateScripts: - return persistenceService.updateProjectScripts(request.params as never); + return stateSyncEngine.updateProjectScripts(request.params as never); case WS_METHODS.shellOpenInEditor: { const params = request.params as { @@ -551,10 +558,13 @@ export function createServer(options: ServerOptions) { async function stop(): Promise { terminalManager.off("event", onTerminalEvent); - persistenceService.off("stateEvent", onStateEvent); + unsubscribeStateEvents(); providerManager.stopAll(); providerManager.dispose(); terminalManager.dispose(); + if (ownsStateSyncEngine) { + stateSyncEngine.close(); + } if (ownsPersistenceService) { persistenceService.close(); } diff --git a/bun.lock b/bun.lock index 313c4c8faee6..db14ae2ffe73 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,8 @@ "t3": "./dist/index.mjs", }, "dependencies": { + "@livestore/adapter-node": "^0.3.1", + "@livestore/livestore": "^0.3.1", "@pierre/diffs": "^1.1.0-beta.16", "node-pty": "^1.1.0", "open": "^10.1.0", @@ -145,6 +147,34 @@ "@base-ui/utils": ["@base-ui/utils@0.2.5", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw=="], + "@effect/cli": ["@effect/cli@0.61.8", "", { "dependencies": { "ini": "^4.1.3", "toml": "^3.0.0", "yaml": "^2.5.0" }, "peerDependencies": { "@effect/platform": "^0.82.8", "@effect/printer": "^0.43.4", "@effect/printer-ansi": "^0.43.4", "effect": "^3.15.4" } }, "sha512-CRdzBUW3SkjsL1Og5bnh7nt1VN0JwNLyfnjlzTWwtIm07WFFdcZXUXquFJfmU++eTP5Kfp8JbZYvsHwmzg/q1w=="], + + "@effect/cluster": ["@effect/cluster@0.34.5", "", { "peerDependencies": { "@effect/platform": "^0.82.8", "@effect/rpc": "^0.59.9", "@effect/sql": "^0.35.8", "effect": "^3.15.4" } }, "sha512-QlXfP8sTTijyw0XQdMFmjIVywqhrbrIuQ64RIazuNdiHkBMLGqkXPerFXdQwaeDeCb8iWy4vAUZSPqThCeHIdw=="], + + "@effect/experimental": ["@effect/experimental@0.46.8", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.82.8", "effect": "^3.15.4", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-2XasqFLQZFy2PGybFdBjgflXFfPuorc/6DMxogeTDHknZa0RH4KoJQQlWaCngbKMsZrZcv0jV+Zi4aDZsVGl7g=="], + + "@effect/opentelemetry": ["@effect/opentelemetry@0.48.8", "", { "peerDependencies": { "@effect/platform": "^0.82.8", "@opentelemetry/api": "^1.9", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^3.15.4" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/resources", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-ZULA3TSLVx3ylcWcmwg5wHiDIMc+LWPxUQVEqE5I+PgqNcPuhk11DjLO+ZYPWbQ2nF7NsawMactNOVCZLMQa7Q=="], + + "@effect/platform": ["@effect/platform@0.82.8", "", { "dependencies": { "find-my-way-ts": "^0.1.5", "msgpackr": "^1.11.4", "multipasta": "^0.2.5" }, "peerDependencies": { "effect": "^3.15.4" } }, "sha512-JEnIACWM2CfyKbf31dPzC903iF11SfiAk74Xs43Ez+n1ieQz2Ew2S/99KBbuesIKkDScLtn8PI23EBFMQiL0Ow=="], + + "@effect/platform-browser": ["@effect/platform-browser@0.62.8", "", { "dependencies": { "multipasta": "^0.2.5" }, "peerDependencies": { "@effect/platform": "^0.82.8", "effect": "^3.15.4" } }, "sha512-xMLpEOrQcZnqZH/DIBjZdYo6W2gtFPgbrt8uxKhDg/GrEXPLJW/yxuKh7JCO/yuQWWZf6o/qhgEVHB406ppwYg=="], + + "@effect/platform-bun": ["@effect/platform-bun@0.65.5", "", { "dependencies": { "@effect/platform-node-shared": "^0.35.5", "multipasta": "^0.2.5" }, "peerDependencies": { "@effect/cluster": "^0.34.5", "@effect/platform": "^0.82.8", "@effect/rpc": "^0.59.9", "@effect/sql": "^0.35.8", "effect": "^3.15.4" } }, "sha512-XAbcJ9UbyCHzEWrUg2kGrZBAnQgVR6MRva1NWf7oU9+Q7SgDuMriNhvIOm9OTjS5KWrwTlzYa8Lr0gmCNEkewQ=="], + + "@effect/platform-node": ["@effect/platform-node@0.81.5", "", { "dependencies": { "@effect/platform-node-shared": "^0.35.5", "mime": "^3.0.0", "undici": "^7.10.0", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.34.5", "@effect/platform": "^0.82.8", "@effect/rpc": "^0.59.9", "@effect/sql": "^0.35.8", "effect": "^3.15.4" } }, "sha512-iRJolgh/DWjs6LjhDY38XtWwxI42sXfdKYD25syl2I1sUm4j/+NBaMOA4OPyf6DNKHIYU82nVyXEGNtrBlFWww=="], + + "@effect/platform-node-shared": ["@effect/platform-node-shared@0.35.5", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "multipasta": "^0.2.5", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.34.5", "@effect/platform": "^0.82.8", "@effect/rpc": "^0.59.9", "@effect/sql": "^0.35.8", "effect": "^3.15.4" } }, "sha512-F0Hh+aueRxOw2vqo9LNqWh31ch5WBhz0Hp0vYQ8jntjLOsvN5oX/0JbGO4E5BNsMZV5HCraP6I4ACjP3KUnvCQ=="], + + "@effect/printer": ["@effect/printer@0.43.5", "", { "peerDependencies": { "@effect/typeclass": "^0.34.5", "effect": "^3.15.5" } }, "sha512-0e+GlRzP1EMFlkkNozjvlNGq2T5VgstO6BDffY+MSqic6OZyd3nyM83CCHOCOT0+0legzr7qaHtY4WdCb0GdLA=="], + + "@effect/printer-ansi": ["@effect/printer-ansi@0.43.5", "", { "dependencies": { "@effect/printer": "^0.43.5" }, "peerDependencies": { "@effect/typeclass": "^0.34.5", "effect": "^3.15.5" } }, "sha512-EQXEKevlBIZPoVGhUi1gybkMrJc9zBKr8SiCN+xVvf2qW6K0I/oOGw58lqWXA3gKfughZBX8MkCVfqWDJlrpmw=="], + + "@effect/rpc": ["@effect/rpc@0.59.9", "", { "peerDependencies": { "@effect/platform": "^0.82.8", "effect": "^3.15.4" } }, "sha512-9Nw6YRBbTgsCyVb7/AMCTML7cE6v38cN0I7tP05XAlvKdmCroifaNopDCO7w1C3EPeNBG42nYuYnwU6L0Atnhg=="], + + "@effect/sql": ["@effect/sql@0.35.8", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.33.0", "uuid": "^11.0.3" }, "peerDependencies": { "@effect/experimental": "^0.46.8", "@effect/platform": "^0.82.8", "effect": "^3.15.4" } }, "sha512-M1x6XMWkIHMY7iHbZgHVpq1jhhX+oFBN5LcL7Oc7cy3h2rtWf9VH2WvC2ivc2gscoKSpITlxEhGNHDFy9YrCSw=="], + + "@effect/typeclass": ["@effect/typeclass@0.34.5", "", { "peerDependencies": { "effect": "^3.15.5" } }, "sha512-nC00DQoD2Tx7EMC1B1UvgPLr7QsG5Ck2nHn94LZl5ddteDNQLPAVkAWSKM+BE5ILyE1eMTblQ+EcomPW2rtUdg=="], + "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -245,6 +275,38 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@livestore/adapter-node": ["@livestore/adapter-node@0.3.1", "", { "dependencies": { "@livestore/common": "0.3.1", "@livestore/devtools-vite": "0.3.1", "@livestore/sqlite-wasm": "0.3.1", "@livestore/utils": "0.3.1", "@livestore/webmesh": "0.3.1", "@opentelemetry/api": "1.9.0", "vite": "6.3.4" } }, "sha512-qP6nGY8du3r+4aS4yJ2YgaDVp/KzE/qBzOzleFTBR9DEM0faeLI9EXzwVq9A/k8pbxm5heF78l0ConExxxl42Q=="], + + "@livestore/adapter-web": ["@livestore/adapter-web@0.3.1", "", { "dependencies": { "@livestore/common": "0.3.1", "@livestore/devtools-web-common": "0.3.1", "@livestore/sqlite-wasm": "0.3.1", "@livestore/utils": "0.3.1", "@livestore/webmesh": "0.3.1", "@opentelemetry/api": "1.9.0" } }, "sha512-0ZKTam+C3KeG5qiiTLFOgoMhdUBCENmuaMwxoj7WOtA2c4eKyqIy+P/1zga+az0XqaMJoG5FVszWhWMUMzNjtQ=="], + + "@livestore/common": ["@livestore/common@0.3.1", "", { "dependencies": { "@livestore/utils": "0.3.1", "@livestore/webmesh": "0.3.1", "@opentelemetry/api": "1.9.0", "graphology": "0.26.0-alpha1", "graphology-dag": "0.4.1", "graphology-types": "0.24.8" } }, "sha512-D44ia+zVH8EF0TWGUp0GQnCVvCsGKgKD/ryFl+kx607/l7GDUSagNnWkRmMwhhshJSJblQwouhA9fwjDNTBB2g=="], + + "@livestore/devtools-vite": ["@livestore/devtools-vite@0.3.1", "", { "dependencies": { "@livestore/adapter-web": "0.3.1", "@livestore/utils": "0.3.1", "vite": "*" } }, "sha512-7d4dxI1jkNtMRNxfnj7WVFIg+z5+M1FxTCUw4XrtHYeJGd7qy3GOJ/75D0lU12OQ7EbY/qSVnoU7++SkSEjMFA=="], + + "@livestore/devtools-web-common": ["@livestore/devtools-web-common@0.3.1", "", { "dependencies": { "@livestore/common": "0.3.1", "@livestore/utils": "0.3.1", "@livestore/webmesh": "0.3.1" } }, "sha512-jU7IvebelTtvSTQ2zKNzSm5qZodwUiPsNsDURsY5Yw0DXpafcfpRobs/WYd+Wd4Q1E4oFaK05hPfWZGbVFdw7Q=="], + + "@livestore/livestore": ["@livestore/livestore@0.3.1", "", { "dependencies": { "@livestore/common": "0.3.1", "@livestore/utils": "0.3.1", "@opentelemetry/api": "1.9.0" } }, "sha512-imw1JK5i82yTmSC4PRoZ1gUiG39jasR49c5TPQI/MCIFSbgqcKS0x1IPoB8js6tlPoVm+iH7p4JTAQKYC2q3Pg=="], + + "@livestore/sqlite-wasm": ["@livestore/sqlite-wasm@0.3.1", "", { "dependencies": { "@livestore/common": "0.3.1", "@livestore/utils": "0.3.1", "@livestore/wa-sqlite": "1.0.5-dev.2" } }, "sha512-+JLIwpd2N214LmwlUX6arYnJ5IhvSi4Fn3Jk6E6fNfuUU4tczcL1PtgfIX2IoiPhn9QDG0YJVdJBT6KYi1uMyg=="], + + "@livestore/utils": ["@livestore/utils@0.3.1", "", { "dependencies": { "@standard-schema/spec": "1.0.0", "msgpackr": "1.11.2", "nanoid": "5.1.3", "pretty-bytes": "6.1.1" }, "peerDependencies": { "@effect/cli": "~0.61.4", "@effect/cluster": "~0.34.1", "@effect/experimental": "~0.46.4", "@effect/opentelemetry": "~0.48.4", "@effect/platform": "~0.82.4", "@effect/platform-browser": "~0.62.4", "@effect/platform-bun": "~0.65.1", "@effect/platform-node": "~0.81.1", "@effect/printer": "~0.43.2", "@effect/printer-ansi": "~0.43.2", "@effect/rpc": "~0.59.5", "@effect/sql": "~0.35.4", "@effect/typeclass": "~0.34.2", "@opentelemetry/api": "~1.9.0", "@opentelemetry/resources": "~2.0.0", "effect": "~3.15.2" } }, "sha512-DFgrKpVIu/Nn9GE0QG2uitrFLF63aIiV5rencAk3kigNPzq4iBao05wvT/JVqgRxxiM7F1SuAZYogwM4QdpcDA=="], + + "@livestore/wa-sqlite": ["@livestore/wa-sqlite@1.0.5-dev.2", "", {}, "sha512-i7tKFEp0m3+4tGLV9Z5eAr/3RZPuNOyuf4NaQ/ygF2KwzlHc3TnqouPuE2sMH06r3soXIilWYND/x0eT8x6D6Q=="], + + "@livestore/webmesh": ["@livestore/webmesh@0.3.1", "", { "dependencies": { "@livestore/utils": "0.3.1" } }, "sha512-JLYFgiOf1r7xqN7SkwdHNZdZ+geQlEdC9rg81DeC839vqqshA5TEhh045dhEkeyVJZqeg44vX5adUX6FOkhTfg=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + "@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], @@ -255,6 +317,14 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.0.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.39.0", "", {}, "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg=="], + "@oxc-project/runtime": ["@oxc-project/runtime@0.112.0", "", {}, "sha512-4vYtWXMnXM6EaweCxbJ6bISAhkNHeN33SihvuX3wrpqaSJA4ZEoW35i9mSvE74+GDf1yTeVE+aEHA+WBpjDk/g=="], "@oxc-project/types": ["@oxc-project/types@0.112.0", "", {}, "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ=="], @@ -313,6 +383,34 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.46.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DJbQsSJUr4KSi9uU0QqOgI7PX2C+fKGZX+YDprt3vM2sC0dWZsgVTLoN2vtkNyEWJSY2mnvRFUshWXT3bmo0Ug=="], + "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.16", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-McjTuEPuacSIcXdoI2O9W6VSHIOs9ApEHnEUwONKZnKqIo2GGv1vNg9Pr8tgBOL7lgBWNEHX5ROJ5z1X74sENQ=="], "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], @@ -687,6 +785,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "effect": ["effect@3.15.5", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-9IA4xu9niyDIuWkS/whroDj3u2nbccw+9fQ9sKCzaEtuwTV5SSoNvp8XOozeSF/0Nwnh+mx2+ShYYgSueOTmMw=="], + "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], @@ -727,18 +827,24 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -773,6 +879,14 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphology": ["graphology@0.26.0-alpha1", "", { "dependencies": { "events": "^3.3.0", "obliterator": "^2.0.2" }, "peerDependencies": { "graphology-types": ">=0.24.0" } }, "sha512-XQ3b+P3Vvljq/mF+O15R5PlR8kVpRL8K8vhJWW9Enh2+iGNn5c3WZNpXP2OaAacfNV3a9gV3Ia6+Qz6do2JZ8w=="], + + "graphology-dag": ["graphology-dag@0.4.1", "", { "dependencies": { "graphology-utils": "^2.4.1", "mnemonist": "^0.39.0" }, "peerDependencies": { "graphology-types": ">=0.19.0" } }, "sha512-3ch9oOAnHZDoT043vyg7ukmSkKJ505nFzaHaYOn0IF2PgGo5VtIavyVK4UpbIa4tli3hhGm1ZTdBsubTmaxu/w=="], + + "graphology-types": ["graphology-types@0.24.8", "", {}, "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q=="], + + "graphology-utils": ["graphology-utils@2.5.2", "", { "peerDependencies": { "graphology-types": ">=0.23.0" } }, "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ=="], + "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -813,6 +927,8 @@ "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], + "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -999,6 +1115,8 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -1007,16 +1125,26 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mnemonist": ["mnemonist@0.39.8", "", { "dependencies": { "obliterator": "^2.0.1" } }, "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msgpackr": ["msgpackr@1.11.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + "msw": ["msw@2.12.10", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw=="], + "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], @@ -1027,6 +1155,8 @@ "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1063,6 +1193,8 @@ "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], @@ -1071,6 +1203,8 @@ "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], @@ -1213,6 +1347,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], @@ -1247,6 +1383,8 @@ "unconfig-core": ["unconfig-core@7.4.2", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg=="], + "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -1277,6 +1415,8 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], @@ -1305,6 +1445,8 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -1341,8 +1483,18 @@ "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@effect/platform/msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], + "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@livestore/adapter-node/vite": ["vite@6.3.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw=="], + + "@livestore/devtools-vite/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "@livestore/utils/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + + "@livestore/utils/nanoid": ["nanoid@5.1.3", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-zAbEOEr7u2CbxwoMRlz/pNSpRP0FdAU4pRaYunCdEezWohXFs+a0Xw7RfkKaezMsmSM1vttcLthJtwRnVtOfHQ=="], + "@tailwindcss/node/lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -1401,6 +1553,8 @@ "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], + "@livestore/adapter-node/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], @@ -1426,5 +1580,57 @@ "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@livestore/adapter-node/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], } } From 8d0af1f9dec75b7117dd72acc8f0ac3fcf9a4b22 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 09:32:02 +0000 Subject: [PATCH 02/42] Add snapshot parity utility for LiveStore migration Co-authored-by: Julius Marminge --- apps/server/src/livestore/parity.test.ts | 149 +++++++++++++++++++++++ apps/server/src/livestore/parity.ts | 96 +++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 apps/server/src/livestore/parity.test.ts create mode 100644 apps/server/src/livestore/parity.ts diff --git a/apps/server/src/livestore/parity.test.ts b/apps/server/src/livestore/parity.test.ts new file mode 100644 index 000000000000..25238408b9be --- /dev/null +++ b/apps/server/src/livestore/parity.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import type { StateBootstrapResult } from "@t3tools/contracts"; +import { diffStateSnapshots, isStateSnapshotInParity } from "./parity"; + +function makeSnapshot(overrides: Partial = {}): StateBootstrapResult { + return { + projects: [], + threads: [], + lastStateSeq: 0, + ...overrides, + }; +} + +describe("diffStateSnapshots", () => { + it("treats snapshots with different ordering as parity-equal", () => { + const projectA = { + id: "project-a", + cwd: "/workspace/a", + name: "A", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + }; + const projectB = { + id: "project-b", + cwd: "/workspace/b", + name: "B", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + }; + const threadA = { + id: "thread-a", + codexThreadId: null, + projectId: "project-a", + title: "Thread A", + model: "gpt-5.3-codex", + terminalOpen: false, + terminalHeight: 280, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + branch: null, + worktreePath: null, + turnDiffSummaries: [ + { + turnId: "turn-2", + completedAt: "2026-02-20T00:00:02.000Z", + files: [], + }, + { + turnId: "turn-1", + completedAt: "2026-02-20T00:00:01.000Z", + files: [], + }, + ], + messages: [ + { + id: "msg-2", + threadId: "thread-a", + role: "assistant" as const, + text: "hello", + createdAt: "2026-02-20T00:00:02.000Z", + updatedAt: "2026-02-20T00:00:02.000Z", + streaming: false, + }, + { + id: "msg-1", + threadId: "thread-a", + role: "user" as const, + text: "hi", + createdAt: "2026-02-20T00:00:01.000Z", + updatedAt: "2026-02-20T00:00:01.000Z", + streaming: false, + }, + ], + }; + + const expected = makeSnapshot({ + projects: [projectA, projectB], + threads: [threadA], + lastStateSeq: 10, + }); + const actual = makeSnapshot({ + projects: [projectB, projectA], + threads: [ + { + ...threadA, + turnDiffSummaries: [...threadA.turnDiffSummaries].toReversed(), + messages: [...threadA.messages].toReversed(), + }, + ], + lastStateSeq: 10, + }); + + expect(diffStateSnapshots(expected, actual)).toEqual([]); + expect(isStateSnapshotInParity(expected, actual)).toBe(true); + }); + + it("reports path-qualified diffs when snapshots diverge", () => { + const expected = makeSnapshot({ + projects: [ + { + id: "project-a", + cwd: "/workspace/a", + name: "Project A", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + }, + ], + lastStateSeq: 5, + }); + const actual = makeSnapshot({ + projects: [ + { + id: "project-a", + cwd: "/workspace/a", + name: "Project A renamed", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + }, + ], + lastStateSeq: 7, + }); + + const diffs = diffStateSnapshots(expected, actual); + expect(diffs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "$.lastStateSeq", + expected: 5, + actual: 7, + }), + expect.objectContaining({ + path: "$.projects[0].name", + expected: "Project A", + actual: "Project A renamed", + }), + ]), + ); + expect(isStateSnapshotInParity(expected, actual)).toBe(false); + }); +}); diff --git a/apps/server/src/livestore/parity.ts b/apps/server/src/livestore/parity.ts new file mode 100644 index 000000000000..4a1e921092a2 --- /dev/null +++ b/apps/server/src/livestore/parity.ts @@ -0,0 +1,96 @@ +import type { StateBootstrapResult } from "@t3tools/contracts"; + +export interface SnapshotParityDiff { + path: string; + expected: unknown; + actual: unknown; +} + +function sortById(items: readonly T[]): T[] { + return [...items].sort((a, b) => a.id.localeCompare(b.id)); +} + +function normalizeSnapshot(snapshot: StateBootstrapResult): StateBootstrapResult { + return { + ...snapshot, + projects: sortById(snapshot.projects), + threads: sortById(snapshot.threads).map((thread) => ({ + ...thread, + messages: [...thread.messages].sort((a, b) => { + if (a.id === b.id) { + return a.createdAt.localeCompare(b.createdAt); + } + return a.id.localeCompare(b.id); + }), + turnDiffSummaries: [...thread.turnDiffSummaries].sort((a, b) => + a.turnId.localeCompare(b.turnId), + ), + })), + }; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function collectDiffs( + expected: unknown, + actual: unknown, + path: string, + diffs: SnapshotParityDiff[], +): void { + if (Object.is(expected, actual)) { + return; + } + + if (Array.isArray(expected) || Array.isArray(actual)) { + if (!Array.isArray(expected) || !Array.isArray(actual)) { + diffs.push({ path, expected, actual }); + return; + } + if (expected.length !== actual.length) { + diffs.push({ + path: `${path}.length`, + expected: expected.length, + actual: actual.length, + }); + return; + } + for (let index = 0; index < expected.length; index += 1) { + collectDiffs(expected[index], actual[index], `${path}[${index}]`, diffs); + } + return; + } + + if (isRecord(expected) || isRecord(actual)) { + if (!isRecord(expected) || !isRecord(actual)) { + diffs.push({ path, expected, actual }); + return; + } + const keys = new Set([...Object.keys(expected), ...Object.keys(actual)]); + for (const key of [...keys].sort((a, b) => a.localeCompare(b))) { + collectDiffs(expected[key], actual[key], `${path}.${key}`, diffs); + } + return; + } + + diffs.push({ path, expected, actual }); +} + +export function diffStateSnapshots( + expectedSnapshot: StateBootstrapResult, + actualSnapshot: StateBootstrapResult, +): SnapshotParityDiff[] { + const expected = normalizeSnapshot(expectedSnapshot); + const actual = normalizeSnapshot(actualSnapshot); + const diffs: SnapshotParityDiff[] = []; + collectDiffs(expected, actual, "$", diffs); + return diffs; +} + +export function isStateSnapshotInParity( + expectedSnapshot: StateBootstrapResult, + actualSnapshot: StateBootstrapResult, +): boolean { + return diffStateSnapshots(expectedSnapshot, actualSnapshot).length === 0; +} From ccacb4bcab6ac63c055eca943773d57355786b89 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 09:42:34 +0000 Subject: [PATCH 03/42] Add wsServer coverage for injected state sync engine Co-authored-by: Julius Marminge --- apps/server/src/wsServer.test.ts | 207 +++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 51f086a090e0..6979785e0844 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -11,6 +11,19 @@ import { DEFAULT_TERMINAL_ID, WS_CHANNELS, WS_METHODS, + type AppSettingsUpdateInput, + type ProjectAddInput, + type ProjectRemoveInput, + type ProjectUpdateScriptsInput, + type StateCatchUpInput, + type StateListMessagesInput, + type ThreadsCreateInput, + type ThreadsDeleteInput, + type ThreadsMarkVisitedInput, + type ThreadsUpdateBranchInput, + type ThreadsUpdateModelInput, + type ThreadsUpdateTerminalStateInput, + type ThreadsUpdateTitleInput, type KeybindingsConfig, type ResolvedKeybindingsConfig, type StateEvent, @@ -30,6 +43,7 @@ import type { import type { TerminalManager } from "./terminalManager"; import { PersistenceService } from "./persistenceService"; import { ProviderManager } from "./providerManager"; +import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; interface PendingMessages { queue: unknown[]; @@ -145,6 +159,157 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } dispose(): void {} } +class MockStateSyncEngine extends EventEmitter<{ stateEvent: [event: StateEvent] }> + implements StateSyncEngine +{ + readonly loadSnapshotMock = vi.fn< + () => { + projects: []; + threads: []; + lastStateSeq: number; + } + >(() => ({ + projects: [], + threads: [], + lastStateSeq: 42, + })); + readonly listMessagesMock = vi.fn(() => ({ + messages: [], + total: 0, + nextOffset: null, + })); + readonly catchUpMock = vi.fn(() => ({ + events: [], + lastStateSeq: 42, + })); + readonly getAppSettingsMock = vi.fn(() => ({ + codexBinaryPath: "", + codexHomePath: "", + })); + readonly updateAppSettingsMock = vi.fn(() => ({ + codexBinaryPath: "", + codexHomePath: "", + })); + readonly createThreadMock = vi.fn(() => ({ + thread: { + id: "thread-1", + codexThreadId: null, + projectId: "project-1", + title: "Thread", + model: "gpt-5.3-codex", + terminalOpen: false, + terminalHeight: 280, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + branch: null, + worktreePath: null, + turnDiffSummaries: [], + }, + })); + readonly updateThreadTerminalStateMock = vi.fn(() => this.createThreadMock()); + readonly updateThreadModelMock = vi.fn(() => this.createThreadMock()); + readonly updateThreadTitleMock = vi.fn(() => this.createThreadMock()); + readonly updateThreadBranchMock = vi.fn(() => this.createThreadMock()); + readonly markThreadVisitedMock = vi.fn(() => this.createThreadMock()); + readonly deleteThreadMock = vi.fn(); + readonly listProjectsMock = vi.fn(() => []); + readonly addProjectMock = vi.fn(() => ({ + project: { + id: "project-1", + cwd: "/tmp/project", + name: "project", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + }, + created: true, + })); + readonly removeProjectMock = vi.fn(); + readonly updateProjectScriptsMock = vi.fn(() => ({ + project: { + id: "project-1", + cwd: "/tmp/project", + name: "project", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + }, + })); + readonly applyCheckpointRevertMock = vi.fn(); + readonly closeMock = vi.fn(); + + onStateEvent(listener: (event: StateEvent) => void): () => void { + this.on("stateEvent", listener); + return () => { + this.off("stateEvent", listener); + }; + } + + emitStateEvent(event: StateEvent): void { + this.emit("stateEvent", event); + } + + loadSnapshot() { + return this.loadSnapshotMock(); + } + listMessages(_raw: StateListMessagesInput) { + return this.listMessagesMock(); + } + catchUp(_raw: StateCatchUpInput) { + return this.catchUpMock(); + } + getAppSettings() { + return this.getAppSettingsMock(); + } + updateAppSettings(_raw: AppSettingsUpdateInput) { + return this.updateAppSettingsMock(); + } + createThread(_raw: ThreadsCreateInput) { + return this.createThreadMock(); + } + updateThreadTerminalState(_raw: ThreadsUpdateTerminalStateInput) { + return this.updateThreadTerminalStateMock(); + } + updateThreadModel(_raw: ThreadsUpdateModelInput) { + return this.updateThreadModelMock(); + } + updateThreadTitle(_raw: ThreadsUpdateTitleInput) { + return this.updateThreadTitleMock(); + } + updateThreadBranch(_raw: ThreadsUpdateBranchInput) { + return this.updateThreadBranchMock(); + } + markThreadVisited(_raw: ThreadsMarkVisitedInput) { + return this.markThreadVisitedMock(); + } + deleteThread(_raw: ThreadsDeleteInput): void { + this.deleteThreadMock(); + } + listProjects() { + return this.listProjectsMock(); + } + addProject(_raw: ProjectAddInput) { + return this.addProjectMock(); + } + removeProject(_raw: ProjectRemoveInput): void { + this.removeProjectMock(); + } + updateProjectScripts(_raw: ProjectUpdateScriptsInput) { + return this.updateProjectScriptsMock(); + } + applyCheckpointRevert(input: ApplyCheckpointRevertInput): void { + this.applyCheckpointRevertMock(input); + } + close(): void { + this.closeMock(); + } +} + function connectWs(port: number, token?: string): Promise { return new Promise((resolve, reject) => { const query = token ? `?token=${encodeURIComponent(token)}` : ""; @@ -256,6 +421,7 @@ describe("WebSocket Server", () => { }; terminalManager?: TerminalManager; persistenceService?: PersistenceService; + stateSyncEngine?: StateSyncEngine; } = {}, ): ReturnType { const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); @@ -272,6 +438,7 @@ describe("WebSocket Server", () => { ...(options.devUrl ? { devUrl: options.devUrl } : {}), ...(options.authToken ? { authToken: options.authToken } : {}), persistenceService, + ...(options.stateSyncEngine ? { stateSyncEngine: options.stateSyncEngine } : {}), ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), }); @@ -763,6 +930,46 @@ describe("WebSocket Server", () => { expect(events.some((event) => event.eventType === "thread.upsert")).toBe(true); }); + it("uses the injected state sync engine for state routes and pushes", async () => { + const shadowEngine = new MockStateSyncEngine(); + server = createTestServer({ + cwd: "/test", + stateSyncEngine: shadowEngine, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const bootstrap = await sendRequest(ws, WS_METHODS.stateBootstrap); + expect(bootstrap.error).toBeUndefined(); + expect(bootstrap.result).toEqual({ + projects: [], + threads: [], + lastStateSeq: 42, + }); + expect(shadowEngine.loadSnapshotMock).toHaveBeenCalledTimes(1); + + const projects = await sendRequest(ws, WS_METHODS.projectsList); + expect(projects.error).toBeUndefined(); + expect(projects.result).toEqual([]); + expect(shadowEngine.listProjectsMock).toHaveBeenCalledTimes(1); + + const mirroredStateEvent: StateEvent = { + seq: 43, + eventType: "thread.upsert", + entityId: "thread-1", + payload: { threadId: "thread-1" }, + createdAt: "2026-02-20T00:00:01.000Z", + }; + shadowEngine.emitStateEvent(mirroredStateEvent); + const pushed = await waitForPush(ws, WS_CHANNELS.stateEvent); + expect(pushed.data).toEqual(mirroredStateEvent); + }); + it("broadcasts ordered state.event pushes", async () => { const stateDir = makeTempDir("t3code-ws-state-events-"); const projectCwd = makeTempDir("t3code-ws-state-events-project-"); From fa390925051dfcb52ef5096c3157579f67869340 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 09:43:38 +0000 Subject: [PATCH 04/42] Cover message events in shadow sync engine parity test Co-authored-by: Julius Marminge --- apps/server/src/stateSyncEngineShadow.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/server/src/stateSyncEngineShadow.test.ts b/apps/server/src/stateSyncEngineShadow.test.ts index a0f1e68d4318..2f3f6939be88 100644 --- a/apps/server/src/stateSyncEngineShadow.test.ts +++ b/apps/server/src/stateSyncEngineShadow.test.ts @@ -65,6 +65,14 @@ describe("ShadowStateSyncEngine", () => { threadId: thread.id, title: "Shadow thread updated", }); + service.bindSessionToThread("shadow-session", thread.id, "runtime-thread-1"); + service.persistUserMessageForTurn({ + sessionId: "shadow-session", + clientMessageId: "msg-1", + clientMessageText: "hello from shadow", + input: "hello from shadow", + attachments: [], + }); await Promise.resolve(); @@ -82,6 +90,8 @@ describe("ShadowStateSyncEngine", () => { expect(mirror.events.map((event) => event.seq)).toEqual( observedEvents.map((event) => event.seq), ); + expect(mirror.events.some((event) => event.eventType === "message.upsert")).toBe(true); + expect(observedEvents.some((event) => event.eventType === "message.upsert")).toBe(true); unsubscribe(); } finally { From dae7ab8359726693e4b707e1693228b04775a7c0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 09:49:15 +0000 Subject: [PATCH 05/42] Project mirrored state events into LiveStore parity snapshot Co-authored-by: Julius Marminge --- .../src/livestore/liveStoreEngine.test.ts | 197 ++++++++++++++++++ apps/server/src/livestore/liveStoreEngine.ts | 156 +++++++++++++- 2 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/livestore/liveStoreEngine.test.ts diff --git a/apps/server/src/livestore/liveStoreEngine.test.ts b/apps/server/src/livestore/liveStoreEngine.test.ts new file mode 100644 index 000000000000..f484647cab66 --- /dev/null +++ b/apps/server/src/livestore/liveStoreEngine.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; +import type { StateBootstrapResult, StateEvent } from "@t3tools/contracts"; +import { diffStateSnapshots, isStateSnapshotInParity } from "./parity"; +import { LiveStoreStateMirror } from "./liveStoreEngine"; + +function makeEvent(input: StateEvent): StateEvent { + return input; +} + +function makeExpectedSnapshot(): StateBootstrapResult { + return { + projects: [ + { + id: "project-1", + cwd: "/workspace/project", + name: "project", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:01.000Z", + }, + ], + threads: [ + { + id: "thread-1", + codexThreadId: null, + projectId: "project-1", + title: "Thread", + model: "gpt-5.3-codex", + terminalOpen: false, + terminalHeight: 280, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:02.000Z", + branch: null, + worktreePath: null, + turnDiffSummaries: [ + { + turnId: "turn-1", + completedAt: "2026-02-20T00:00:04.000Z", + files: [], + }, + ], + messages: [ + { + id: "message-1", + threadId: "thread-1", + role: "user", + text: "hello", + createdAt: "2026-02-20T00:00:03.000Z", + updatedAt: "2026-02-20T00:00:03.000Z", + streaming: false, + }, + ], + }, + ], + lastStateSeq: 4, + }; +} + +describe("LiveStoreStateMirror", () => { + it("projects mirrored state events into a parity snapshot", async () => { + const mirror = new LiveStoreStateMirror({ storeId: "shadow-parity-test" }); + const events: StateEvent[] = [ + makeEvent({ + seq: 1, + eventType: "project.upsert", + entityId: "project-1", + createdAt: "2026-02-20T00:00:01.000Z", + payload: { + project: { + id: "project-1", + cwd: "/workspace/project", + name: "project", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:01.000Z", + }, + }, + }), + makeEvent({ + seq: 2, + eventType: "thread.upsert", + entityId: "thread-1", + createdAt: "2026-02-20T00:00:02.000Z", + payload: { + thread: { + id: "thread-1", + codexThreadId: null, + projectId: "project-1", + title: "Thread", + model: "gpt-5.3-codex", + terminalOpen: false, + terminalHeight: 280, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:02.000Z", + branch: null, + worktreePath: null, + }, + }, + }), + makeEvent({ + seq: 3, + eventType: "message.upsert", + entityId: "thread-1:message-1", + createdAt: "2026-02-20T00:00:03.000Z", + payload: { + threadId: "thread-1", + message: { + id: "message-1", + threadId: "thread-1", + role: "user", + text: "hello", + createdAt: "2026-02-20T00:00:03.000Z", + updatedAt: "2026-02-20T00:00:03.000Z", + streaming: false, + }, + }, + }), + makeEvent({ + seq: 4, + eventType: "turn_summary.upsert", + entityId: "thread-1:turn-1", + createdAt: "2026-02-20T00:00:04.000Z", + payload: { + threadId: "thread-1", + turnSummary: { + turnId: "turn-1", + completedAt: "2026-02-20T00:00:04.000Z", + files: [], + }, + }, + }), + ]; + + try { + for (const event of events) { + await mirror.mirrorStateEvent(event); + } + + const expected = makeExpectedSnapshot(); + const actual = mirror.debugReadSnapshot(); + expect(diffStateSnapshots(expected, actual)).toEqual([]); + expect(isStateSnapshotInParity(expected, actual)).toBe(true); + } finally { + await mirror.dispose(); + } + }); + + it("handles delete events in mirrored projections", async () => { + const mirror = new LiveStoreStateMirror({ storeId: "shadow-delete-test" }); + try { + await mirror.mirrorStateEvent( + makeEvent({ + seq: 1, + eventType: "project.upsert", + entityId: "project-1", + createdAt: "2026-02-20T00:00:00.000Z", + payload: { + project: { + id: "project-1", + cwd: "/workspace/project", + name: "project", + scripts: [], + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + }, + }, + }), + ); + await mirror.mirrorStateEvent( + makeEvent({ + seq: 2, + eventType: "project.delete", + entityId: "project-1", + createdAt: "2026-02-20T00:00:01.000Z", + payload: { projectId: "project-1" }, + }), + ); + + const snapshot = mirror.debugReadSnapshot(); + expect(snapshot.projects).toEqual([]); + expect(snapshot.threads).toEqual([]); + expect(snapshot.lastStateSeq).toBe(2); + } finally { + await mirror.dispose(); + } + }); +}); diff --git a/apps/server/src/livestore/liveStoreEngine.ts b/apps/server/src/livestore/liveStoreEngine.ts index 3d72e3c9b999..7f57fb0bde18 100644 --- a/apps/server/src/livestore/liveStoreEngine.ts +++ b/apps/server/src/livestore/liveStoreEngine.ts @@ -1,6 +1,14 @@ import { makeAdapter } from "@livestore/adapter-node"; import { createStorePromise } from "@livestore/livestore"; -import type { StateEvent } from "@t3tools/contracts"; +import { + stateBootstrapResultSchema, + stateMessageSchema, + stateProjectSchema, + stateThreadSchema, + stateTurnSummarySchema, + type StateBootstrapResult, + type StateEvent, +} from "@t3tools/contracts"; import { createLogger } from "../logger"; import type { StateEventMirror } from "../stateSyncEngineShadow"; import { liveStoreShadowSchema } from "./materializers"; @@ -17,6 +25,17 @@ interface LiveStoreShadowStore { shutdownPromise?: () => Promise; } +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + export class LiveStoreStateMirror implements StateEventMirror { private readonly enabled: boolean; private readonly storeId: string; @@ -25,6 +44,16 @@ export class LiveStoreStateMirror implements StateEventMirror { private storePromise: Promise | null = null; private lastMirroredSeq = 0; private disposed = false; + private readonly projectsById = new Map(); + private readonly threadsById = new Map(); + private readonly threadMessagesByThreadId = new Map< + string, + Map + >(); + private readonly turnSummariesByThreadId = new Map< + string, + Map + >(); constructor(options: LiveStoreStateMirrorOptions = {}) { this.enabled = options.enabled ?? true; @@ -54,6 +83,7 @@ export class LiveStoreStateMirror implements StateEventMirror { createdAt: event.createdAt, }, "shadow"), ); + this.applyProjection(event); this.lastMirroredSeq = event.seq; } catch (error) { this.logger.warn("failed to commit mirrored state event", { @@ -73,6 +103,10 @@ export class LiveStoreStateMirror implements StateEventMirror { this.store = null; this.storePromise = null; this.lastMirroredSeq = 0; + this.projectsById.clear(); + this.threadsById.clear(); + this.threadMessagesByThreadId.clear(); + this.turnSummariesByThreadId.clear(); if (!store) { return; } @@ -130,4 +164,124 @@ export class LiveStoreStateMirror implements StateEventMirror { return null; } } + + debugReadSnapshot(): StateBootstrapResult { + const projects = Array.from(this.projectsById.values()); + const threads = Array.from(this.threadsById.values()).map((thread) => { + const messagesById = this.threadMessagesByThreadId.get(thread.id); + const turnSummariesByTurnId = this.turnSummariesByThreadId.get(thread.id); + return { + ...thread, + messages: messagesById + ? Array.from(messagesById.values()).toSorted((a, b) => { + if (a.createdAt === b.createdAt) { + return a.id.localeCompare(b.id); + } + return a.createdAt.localeCompare(b.createdAt); + }) + : [], + turnDiffSummaries: turnSummariesByTurnId + ? Array.from(turnSummariesByTurnId.values()).toSorted((a, b) => + b.completedAt.localeCompare(a.completedAt), + ) + : [], + }; + }); + + return stateBootstrapResultSchema.parse({ + projects, + threads, + lastStateSeq: this.lastMirroredSeq, + }); + } + + private applyProjection(event: StateEvent): void { + const payload = asObject(event.payload); + + if (event.eventType === "project.upsert") { + const candidate = stateProjectSchema.safeParse(payload?.project); + if (candidate.success) { + this.projectsById.set(candidate.data.id, candidate.data); + } + return; + } + + if (event.eventType === "project.delete") { + const projectId = asString(payload?.projectId) ?? event.entityId; + this.projectsById.delete(projectId); + for (const [threadId, thread] of this.threadsById.entries()) { + if (thread.projectId !== projectId) { + continue; + } + this.threadsById.delete(threadId); + this.threadMessagesByThreadId.delete(threadId); + this.turnSummariesByThreadId.delete(threadId); + } + return; + } + + if (event.eventType === "thread.upsert") { + const candidate = stateThreadSchema.safeParse(payload?.thread); + if (!candidate.success) { + return; + } + this.threadsById.set(candidate.data.id, { + ...candidate.data, + messages: [], + turnDiffSummaries: [], + }); + return; + } + + if (event.eventType === "thread.delete") { + const threadId = asString(payload?.threadId) ?? event.entityId; + this.threadsById.delete(threadId); + this.threadMessagesByThreadId.delete(threadId); + this.turnSummariesByThreadId.delete(threadId); + return; + } + + if (event.eventType === "message.upsert") { + const threadId = asString(payload?.threadId); + const messageCandidate = stateMessageSchema.safeParse(payload?.message); + if (!threadId || !messageCandidate.success) { + return; + } + const byId = this.threadMessagesByThreadId.get(threadId) ?? new Map(); + byId.set(messageCandidate.data.id, messageCandidate.data); + this.threadMessagesByThreadId.set(threadId, byId); + return; + } + + if (event.eventType === "message.delete") { + const threadId = asString(payload?.threadId); + const messageId = asString(payload?.messageId); + if (!threadId || !messageId) { + return; + } + this.threadMessagesByThreadId.get(threadId)?.delete(messageId); + return; + } + + if (event.eventType === "turn_summary.upsert") { + const threadId = asString(payload?.threadId); + const summaryCandidate = stateTurnSummarySchema.safeParse(payload?.turnSummary); + if (!threadId || !summaryCandidate.success) { + return; + } + const byTurnId = this.turnSummariesByThreadId.get(threadId) ?? new Map(); + byTurnId.set(summaryCandidate.data.turnId, summaryCandidate.data); + this.turnSummariesByThreadId.set(threadId, byTurnId); + return; + } + + if (event.eventType === "turn_summary.delete") { + const threadId = asString(payload?.threadId); + const turnId = asString(payload?.turnId); + if (!threadId || !turnId) { + return; + } + this.turnSummariesByThreadId.get(threadId)?.delete(turnId); + } + } } From 40bb6cb7a7ecb7d522492dcef00595f59e7b7d6c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 09:54:30 +0000 Subject: [PATCH 06/42] Add LiveStore read-pilot sync engine with delegate fallback Co-authored-by: Julius Marminge --- apps/server/src/index.ts | 25 +- apps/server/src/livestore/liveStoreEngine.ts | 34 +++ .../stateSyncEngineLiveStoreReadPilot.test.ts | 265 ++++++++++++++++++ .../src/stateSyncEngineLiveStoreReadPilot.ts | 184 ++++++++++++ 4 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts create mode 100644 apps/server/src/stateSyncEngineLiveStoreReadPilot.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 6102a28c54d0..0e19f1f7935c 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -9,6 +9,7 @@ import { createLogger } from "./logger"; import { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; import { PersistenceService } from "./persistenceService"; import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; +import { LiveStoreReadPilotStateSyncEngine } from "./stateSyncEngineLiveStoreReadPilot"; import { ShadowStateSyncEngine } from "./stateSyncEngineShadow"; import { createServer } from "./wsServer"; @@ -19,7 +20,7 @@ const cwd = process.cwd(); const logger = createLogger("server"); type RuntimeMode = "web" | "desktop"; -type SyncEngineMode = "legacy" | "shadow"; +type SyncEngineMode = "legacy" | "shadow" | "livestore-read-pilot"; function parseBooleanEnv(value: string | undefined): boolean | undefined { if (value === undefined) return undefined; @@ -43,11 +44,15 @@ function resolveSyncEngineMode(raw: string | undefined): SyncEngineMode { return "legacy"; } const normalized = raw.trim().toLowerCase(); - if (normalized === "legacy" || normalized === "shadow") { + if ( + normalized === "legacy" || + normalized === "shadow" || + normalized === "livestore-read-pilot" + ) { return normalized; } throw new Error( - `Invalid T3CODE_SYNC_ENGINE_MODE: ${raw}. Expected "legacy" or "shadow".`, + `Invalid T3CODE_SYNC_ENGINE_MODE: ${raw}. Expected "legacy", "shadow", or "livestore-read-pilot".`, ); } @@ -133,15 +138,21 @@ async function main() { legacyProjectsJsonPath: path.join(legacyStateDir, "projects.json"), }); const legacyStateSyncEngine = new LegacyStateSyncEngine({ persistenceService }); + const liveStoreMirror = new LiveStoreStateMirror({ + storeId: `t3-shadow-${mode}`, + }); const stateSyncEngine = syncEngineMode === "shadow" ? new ShadowStateSyncEngine({ delegate: legacyStateSyncEngine, - mirror: new LiveStoreStateMirror({ - storeId: `t3-shadow-${mode}`, - }), + mirror: liveStoreMirror, }) - : legacyStateSyncEngine; + : syncEngineMode === "livestore-read-pilot" + ? new LiveStoreReadPilotStateSyncEngine({ + delegate: legacyStateSyncEngine, + mirror: liveStoreMirror, + }) + : legacyStateSyncEngine; const devUrl = process.env.VITE_DEV_SERVER_URL; const noBrowser = parseBooleanEnv(process.env.T3CODE_NO_BROWSER) ?? mode === "desktop"; const authToken = process.env.T3CODE_AUTH_TOKEN; diff --git a/apps/server/src/livestore/liveStoreEngine.ts b/apps/server/src/livestore/liveStoreEngine.ts index 7f57fb0bde18..f3b24014bd4d 100644 --- a/apps/server/src/livestore/liveStoreEngine.ts +++ b/apps/server/src/livestore/liveStoreEngine.ts @@ -2,12 +2,17 @@ import { makeAdapter } from "@livestore/adapter-node"; import { createStorePromise } from "@livestore/livestore"; import { stateBootstrapResultSchema, + stateCatchUpResultSchema, + stateListMessagesResultSchema, stateMessageSchema, stateProjectSchema, stateThreadSchema, stateTurnSummarySchema, type StateBootstrapResult, + type StateCatchUpResult, type StateEvent, + type StateListMessagesInput, + type StateListMessagesResult, } from "@t3tools/contracts"; import { createLogger } from "../logger"; import type { StateEventMirror } from "../stateSyncEngineShadow"; @@ -54,6 +59,7 @@ export class LiveStoreStateMirror implements StateEventMirror { string, Map >(); + private readonly mirroredEventsBySeq = new Map(); constructor(options: LiveStoreStateMirrorOptions = {}) { this.enabled = options.enabled ?? true; @@ -107,6 +113,7 @@ export class LiveStoreStateMirror implements StateEventMirror { this.threadsById.clear(); this.threadMessagesByThreadId.clear(); this.turnSummariesByThreadId.clear(); + this.mirroredEventsBySeq.clear(); if (!store) { return; } @@ -195,7 +202,34 @@ export class LiveStoreStateMirror implements StateEventMirror { }); } + debugCatchUp(afterSeq: number): StateCatchUpResult { + const events = Array.from(this.mirroredEventsBySeq.entries()) + .filter(([seq]) => seq > afterSeq) + .sort(([a], [b]) => a - b) + .map(([, event]) => event); + return stateCatchUpResultSchema.parse({ + events, + lastStateSeq: this.lastMirroredSeq, + }); + } + + debugListMessages(raw: StateListMessagesInput): StateListMessagesResult { + const snapshot = this.debugReadSnapshot(); + const thread = snapshot.threads.find((candidate) => candidate.id === raw.threadId); + const messages = thread?.messages ?? []; + const offset = Math.max(0, Math.floor(raw.offset ?? 0)); + const limit = Math.max(1, Math.floor(raw.limit ?? 200)); + const paged = messages.slice(offset, offset + limit); + const nextOffset = offset + paged.length; + return stateListMessagesResultSchema.parse({ + messages: paged, + total: messages.length, + nextOffset: nextOffset < messages.length ? nextOffset : null, + }); + } + private applyProjection(event: StateEvent): void { + this.mirroredEventsBySeq.set(event.seq, event); const payload = asObject(event.payload); if (event.eventType === "project.upsert") { diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts new file mode 100644 index 000000000000..b186f73bb7c4 --- /dev/null +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts @@ -0,0 +1,265 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import type { + AppSettings, + AppSettingsUpdateInput, + ProjectAddInput, + ProjectAddResult, + ProjectListResult, + ProjectRemoveInput, + ProjectUpdateScriptsInput, + ProjectUpdateScriptsResult, + StateBootstrapResult, + StateCatchUpInput, + StateCatchUpResult, + StateEvent, + StateListMessagesInput, + StateListMessagesResult, + ThreadsCreateInput, + ThreadsDeleteInput, + ThreadsMarkVisitedInput, + ThreadsUpdateBranchInput, + ThreadsUpdateModelInput, + ThreadsUpdateResult, + ThreadsUpdateTerminalStateInput, + ThreadsUpdateTitleInput, +} from "@t3tools/contracts"; +import type { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; +import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; +import { LiveStoreReadPilotStateSyncEngine } from "./stateSyncEngineLiveStoreReadPilot"; + +class MockDelegateStateSyncEngine extends EventEmitter<{ stateEvent: [event: StateEvent] }> + implements StateSyncEngine +{ + readonly loadSnapshotMock = vi.fn<() => StateBootstrapResult>(() => ({ + projects: [], + threads: [], + lastStateSeq: 1, + })); + readonly listMessagesMock = vi.fn<(raw: StateListMessagesInput) => StateListMessagesResult>(() => ({ + messages: [], + total: 0, + nextOffset: null, + })); + readonly catchUpMock = vi.fn<(raw: StateCatchUpInput) => StateCatchUpResult>(() => ({ + events: [], + lastStateSeq: 1, + })); + readonly getAppSettingsMock = vi.fn<() => AppSettings>(() => ({ + codexBinaryPath: "", + codexHomePath: "", + })); + readonly updateAppSettingsMock = vi.fn<(raw: AppSettingsUpdateInput) => AppSettings>(() => ({ + codexBinaryPath: "", + codexHomePath: "", + })); + readonly createThreadMock = vi.fn<(raw: ThreadsCreateInput) => ThreadsUpdateResult>(() => { + throw new Error("not implemented in test"); + }); + readonly updateThreadTerminalStateMock = vi.fn< + (raw: ThreadsUpdateTerminalStateInput) => ThreadsUpdateResult + >(() => { + throw new Error("not implemented in test"); + }); + readonly updateThreadModelMock = vi.fn<(raw: ThreadsUpdateModelInput) => ThreadsUpdateResult>(() => { + throw new Error("not implemented in test"); + }); + readonly updateThreadTitleMock = vi.fn<(raw: ThreadsUpdateTitleInput) => ThreadsUpdateResult>(() => { + throw new Error("not implemented in test"); + }); + readonly updateThreadBranchMock = vi.fn<(raw: ThreadsUpdateBranchInput) => ThreadsUpdateResult>(() => { + throw new Error("not implemented in test"); + }); + readonly markThreadVisitedMock = vi.fn<(raw: ThreadsMarkVisitedInput) => ThreadsUpdateResult>(() => { + throw new Error("not implemented in test"); + }); + readonly deleteThreadMock = vi.fn<(raw: ThreadsDeleteInput) => void>(() => {}); + readonly listProjectsMock = vi.fn<() => ProjectListResult>(() => []); + readonly addProjectMock = vi.fn<(raw: ProjectAddInput) => ProjectAddResult>(() => { + throw new Error("not implemented in test"); + }); + readonly removeProjectMock = vi.fn<(raw: ProjectRemoveInput) => void>(() => {}); + readonly updateProjectScriptsMock = vi.fn< + (raw: ProjectUpdateScriptsInput) => ProjectUpdateScriptsResult + >(() => { + throw new Error("not implemented in test"); + }); + readonly applyCheckpointRevertMock = vi.fn<(input: ApplyCheckpointRevertInput) => void>(() => {}); + readonly closeMock = vi.fn(); + + onStateEvent(listener: (event: StateEvent) => void): () => void { + this.on("stateEvent", listener); + return () => { + this.off("stateEvent", listener); + }; + } + + emitStateEvent(event: StateEvent): void { + this.emit("stateEvent", event); + } + + loadSnapshot(): StateBootstrapResult { + return this.loadSnapshotMock(); + } + listMessages(raw: StateListMessagesInput): StateListMessagesResult { + return this.listMessagesMock(raw); + } + catchUp(raw: StateCatchUpInput): StateCatchUpResult { + return this.catchUpMock(raw); + } + getAppSettings(): AppSettings { + return this.getAppSettingsMock(); + } + updateAppSettings(raw: AppSettingsUpdateInput): AppSettings { + return this.updateAppSettingsMock(raw); + } + createThread(raw: ThreadsCreateInput): ThreadsUpdateResult { + return this.createThreadMock(raw); + } + updateThreadTerminalState(raw: ThreadsUpdateTerminalStateInput): ThreadsUpdateResult { + return this.updateThreadTerminalStateMock(raw); + } + updateThreadModel(raw: ThreadsUpdateModelInput): ThreadsUpdateResult { + return this.updateThreadModelMock(raw); + } + updateThreadTitle(raw: ThreadsUpdateTitleInput): ThreadsUpdateResult { + return this.updateThreadTitleMock(raw); + } + updateThreadBranch(raw: ThreadsUpdateBranchInput): ThreadsUpdateResult { + return this.updateThreadBranchMock(raw); + } + markThreadVisited(raw: ThreadsMarkVisitedInput): ThreadsUpdateResult { + return this.markThreadVisitedMock(raw); + } + deleteThread(raw: ThreadsDeleteInput): void { + this.deleteThreadMock(raw); + } + listProjects(): ProjectListResult { + return this.listProjectsMock(); + } + addProject(raw: ProjectAddInput): ProjectAddResult { + return this.addProjectMock(raw); + } + removeProject(raw: ProjectRemoveInput): void { + this.removeProjectMock(raw); + } + updateProjectScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult { + return this.updateProjectScriptsMock(raw); + } + applyCheckpointRevert(input: ApplyCheckpointRevertInput): void { + this.applyCheckpointRevertMock(input); + } + close(): void { + this.closeMock(); + } +} + +function makeMirrorStub(input: { + snapshot: StateBootstrapResult; + catchUp: StateCatchUpResult; + listMessages: StateListMessagesResult; +}): LiveStoreStateMirror { + return { + mirrorStateEvent: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + debugReadSnapshot: vi.fn(() => input.snapshot), + debugCatchUp: vi.fn(() => input.catchUp), + debugListMessages: vi.fn(() => input.listMessages), + } as unknown as LiveStoreStateMirror; +} + +describe("LiveStoreReadPilotStateSyncEngine", () => { + it("serves bootstrap/catchUp/listMessages from mirror when available", () => { + const delegate = new MockDelegateStateSyncEngine(); + const mirrorSnapshot: StateBootstrapResult = { + projects: [], + threads: [], + lastStateSeq: 5, + }; + const mirrorCatchUp: StateCatchUpResult = { + events: [], + lastStateSeq: 5, + }; + const mirrorList: StateListMessagesResult = { + messages: [], + total: 0, + nextOffset: null, + }; + const mirror = makeMirrorStub({ + snapshot: mirrorSnapshot, + catchUp: mirrorCatchUp, + listMessages: mirrorList, + }); + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + }); + + try { + expect(engine.loadSnapshot()).toEqual(mirrorSnapshot); + expect(engine.catchUp({ afterSeq: 0 })).toEqual(mirrorCatchUp); + expect(engine.listMessages({ threadId: "thread-1", offset: 0, limit: 10 })).toEqual(mirrorList); + expect(delegate.loadSnapshotMock).not.toHaveBeenCalled(); + expect(delegate.catchUpMock).not.toHaveBeenCalled(); + expect(delegate.listMessagesMock).not.toHaveBeenCalled(); + } finally { + engine.close(); + } + }); + + it("falls back to delegate reads when mirror throws", () => { + const delegate = new MockDelegateStateSyncEngine(); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 2, + }, + catchUp: { + events: [], + lastStateSeq: 2, + }, + listMessages: { + messages: [], + total: 0, + nextOffset: null, + }, + }); + vi.mocked(mirror.debugReadSnapshot).mockImplementationOnce(() => { + throw new Error("mirror bootstrap failure"); + }); + vi.mocked(mirror.debugCatchUp).mockImplementationOnce(() => { + throw new Error("mirror catch-up failure"); + }); + vi.mocked(mirror.debugListMessages).mockImplementationOnce(() => { + throw new Error("mirror list failure"); + }); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + }); + + try { + expect(engine.loadSnapshot()).toEqual({ + projects: [], + threads: [], + lastStateSeq: 1, + }); + expect(engine.catchUp({ afterSeq: 0 })).toEqual({ + events: [], + lastStateSeq: 1, + }); + expect(engine.listMessages({ threadId: "thread-1", offset: 0, limit: 10 })).toEqual({ + messages: [], + total: 0, + nextOffset: null, + }); + expect(delegate.loadSnapshotMock).toHaveBeenCalledTimes(1); + expect(delegate.catchUpMock).toHaveBeenCalledTimes(1); + expect(delegate.listMessagesMock).toHaveBeenCalledTimes(1); + } finally { + engine.close(); + } + }); +}); diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts new file mode 100644 index 000000000000..0eee80a6234b --- /dev/null +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts @@ -0,0 +1,184 @@ +import { EventEmitter } from "node:events"; +import type { + AppSettings, + AppSettingsUpdateInput, + ProjectAddInput, + ProjectAddResult, + ProjectListResult, + ProjectRemoveInput, + ProjectUpdateScriptsInput, + ProjectUpdateScriptsResult, + StateBootstrapResult, + StateCatchUpInput, + StateCatchUpResult, + StateEvent, + StateListMessagesInput, + StateListMessagesResult, + ThreadsCreateInput, + ThreadsDeleteInput, + ThreadsMarkVisitedInput, + ThreadsUpdateBranchInput, + ThreadsUpdateModelInput, + ThreadsUpdateResult, + ThreadsUpdateTerminalStateInput, + ThreadsUpdateTitleInput, +} from "@t3tools/contracts"; +import { createLogger } from "./logger"; +import type { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; +import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; + +interface LiveStoreReadPilotEvents { + stateEvent: [event: StateEvent]; +} + +export interface LiveStoreReadPilotStateSyncEngineOptions { + delegate: StateSyncEngine; + mirror: LiveStoreStateMirror; +} + +export class LiveStoreReadPilotStateSyncEngine + extends EventEmitter + implements StateSyncEngine +{ + private readonly delegate: StateSyncEngine; + private readonly mirror: LiveStoreStateMirror; + private readonly logger = createLogger("livestore-read-pilot"); + private readonly unsubscribeDelegate: () => void; + private closed = false; + private bootstrapSource: "delegate" | "livestore" = "delegate"; + + constructor(options: LiveStoreReadPilotStateSyncEngineOptions) { + super(); + this.delegate = options.delegate; + this.mirror = options.mirror; + this.unsubscribeDelegate = this.delegate.onStateEvent((event) => { + this.emit("stateEvent", event); + void this.mirror.mirrorStateEvent(event).catch((error) => { + this.logger.warn("failed to mirror state event in read pilot", { + error, + seq: event.seq, + }); + }); + }); + } + + onStateEvent(listener: (event: StateEvent) => void): () => void { + this.on("stateEvent", listener); + return () => { + this.off("stateEvent", listener); + }; + } + + loadSnapshot(): StateBootstrapResult { + try { + const snapshot = this.mirror.debugReadSnapshot(); + if (snapshot.lastStateSeq > 0) { + if (this.bootstrapSource !== "livestore") { + this.bootstrapSource = "livestore"; + this.logger.info("serving state.bootstrap from livestore mirror", { + lastStateSeq: snapshot.lastStateSeq, + }); + } + return snapshot; + } + } catch (error) { + this.logger.warn("failed to read bootstrap from livestore mirror; using delegate", { error }); + } + if (this.bootstrapSource !== "delegate") { + this.bootstrapSource = "delegate"; + this.logger.info("serving state.bootstrap from delegate fallback"); + } + return this.delegate.loadSnapshot(); + } + + listMessages(raw: StateListMessagesInput): StateListMessagesResult { + try { + return this.mirror.debugListMessages(raw); + } catch (error) { + this.logger.warn("failed to list messages from livestore mirror; using delegate", { + error, + threadId: raw.threadId, + }); + return this.delegate.listMessages(raw); + } + } + + catchUp(raw: StateCatchUpInput): StateCatchUpResult { + const afterSeq = raw.afterSeq ?? 0; + try { + return this.mirror.debugCatchUp(afterSeq); + } catch (error) { + this.logger.warn("failed to catch up from livestore mirror; using delegate", { + error, + afterSeq, + }); + return this.delegate.catchUp(raw); + } + } + + getAppSettings(): AppSettings { + return this.delegate.getAppSettings(); + } + + updateAppSettings(raw: AppSettingsUpdateInput): AppSettings { + return this.delegate.updateAppSettings(raw); + } + + createThread(raw: ThreadsCreateInput): ThreadsUpdateResult { + return this.delegate.createThread(raw); + } + + updateThreadTerminalState(raw: ThreadsUpdateTerminalStateInput): ThreadsUpdateResult { + return this.delegate.updateThreadTerminalState(raw); + } + + updateThreadModel(raw: ThreadsUpdateModelInput): ThreadsUpdateResult { + return this.delegate.updateThreadModel(raw); + } + + updateThreadTitle(raw: ThreadsUpdateTitleInput): ThreadsUpdateResult { + return this.delegate.updateThreadTitle(raw); + } + + updateThreadBranch(raw: ThreadsUpdateBranchInput): ThreadsUpdateResult { + return this.delegate.updateThreadBranch(raw); + } + + markThreadVisited(raw: ThreadsMarkVisitedInput): ThreadsUpdateResult { + return this.delegate.markThreadVisited(raw); + } + + deleteThread(raw: ThreadsDeleteInput): void { + this.delegate.deleteThread(raw); + } + + listProjects(): ProjectListResult { + return this.delegate.listProjects(); + } + + addProject(raw: ProjectAddInput): ProjectAddResult { + return this.delegate.addProject(raw); + } + + removeProject(raw: ProjectRemoveInput): void { + this.delegate.removeProject(raw); + } + + updateProjectScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult { + return this.delegate.updateProjectScripts(raw); + } + + applyCheckpointRevert(input: ApplyCheckpointRevertInput): void { + this.delegate.applyCheckpointRevert(input); + } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + this.unsubscribeDelegate(); + this.removeAllListeners(); + void this.mirror.dispose(); + } +} From 637eae09a4954adcc472b68bcb5c57a1ed0a1580 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 09:58:01 +0000 Subject: [PATCH 07/42] Add web state source seam for sync provider migration Co-authored-by: Julius Marminge --- apps/web/src/routes/__root.tsx | 18 +-- apps/web/src/stateSource.test.ts | 197 +++++++++++++++++++++++++++++++ apps/web/src/stateSource.ts | 21 ++++ 3 files changed, 228 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/stateSource.test.ts create mode 100644 apps/web/src/stateSource.ts diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 7032c327188c..2e62415ac81c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -4,7 +4,7 @@ import { type ErrorComponentProps, useParams, } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; @@ -13,6 +13,7 @@ import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; import { isElectron } from "../env"; import { useNativeApi } from "../hooks/useNativeApi"; import { invalidateGitQueries } from "../lib/gitReactQuery"; +import { createStateSource } from "../stateSource"; import { useStore } from "../store"; import { onServerWelcome } from "../wsNativeApi"; @@ -131,16 +132,17 @@ function StateSyncRouter() { }); const lastStateSeqRef = useRef(0); const stateQueueRef = useRef(Promise.resolve()); + const stateSource = useMemo(() => (api ? createStateSource(api) : null), [api]); useEffect(() => { - if (!api) return; + if (!api || !stateSource) return; let disposed = false; let retryDelayMs = 500; let retryTimer: ReturnType | null = null; const bootstrap = async () => { try { - const snapshot = await api.state.bootstrap(); + const snapshot = await stateSource.bootstrap(); if (disposed) return; dispatch({ type: "HYDRATE_FROM_SERVER", @@ -165,11 +167,11 @@ function StateSyncRouter() { clearTimeout(retryTimer); } }; - }, [api, dispatch]); + }, [api, dispatch, stateSource]); useEffect(() => { - if (!api) return; - return api.state.onEvent((event) => { + if (!api || !stateSource) return; + return stateSource.onEvent((event) => { stateQueueRef.current = stateQueueRef.current .then(async () => { if (event.seq <= lastStateSeqRef.current) { @@ -177,7 +179,7 @@ function StateSyncRouter() { } if (event.seq > lastStateSeqRef.current + 1) { - const catchUp = await api.state.catchUp({ afterSeq: lastStateSeqRef.current }); + const catchUp = await stateSource.catchUp({ afterSeq: lastStateSeqRef.current }); for (const missingEvent of catchUp.events) { if (missingEvent.seq <= lastStateSeqRef.current) continue; dispatch({ @@ -198,7 +200,7 @@ function StateSyncRouter() { }) .catch(() => undefined); }); - }, [api, dispatch]); + }, [api, dispatch, stateSource]); useEffect(() => { if (!api) return; diff --git a/apps/web/src/stateSource.test.ts b/apps/web/src/stateSource.test.ts new file mode 100644 index 000000000000..186c9f2c938d --- /dev/null +++ b/apps/web/src/stateSource.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from "vitest"; +import type { NativeApi, StateEvent } from "@t3tools/contracts"; +import { createStateSource } from "./stateSource"; + +function makeApi(): NativeApi { + const onStateEvent = vi.fn<(callback: (event: StateEvent) => void) => () => void>((callback) => { + callback({ + seq: 1, + eventType: "project.upsert", + entityId: "project-1", + payload: {}, + createdAt: "2026-02-20T00:00:00.000Z", + }); + return () => undefined; + }); + + return { + todos: { + list: async () => [], + add: async () => [], + toggle: async () => [], + remove: async () => [], + }, + dialogs: { + pickFolder: async () => null, + confirm: async () => true, + }, + terminal: { + open: vi.fn(async () => { + throw new Error("not implemented"); + }), + write: vi.fn(async () => undefined), + resize: vi.fn(async () => undefined), + clear: vi.fn(async () => undefined), + restart: vi.fn(async () => { + throw new Error("not implemented"); + }), + close: vi.fn(async () => undefined), + onEvent: vi.fn(() => () => undefined), + }, + agent: { + spawn: vi.fn(async () => ""), + kill: vi.fn(async () => undefined), + write: vi.fn(async () => undefined), + onOutput: vi.fn(() => () => undefined), + onExit: vi.fn(() => () => undefined), + }, + providers: { + startSession: vi.fn(async () => { + throw new Error("not implemented"); + }), + sendTurn: vi.fn(async () => { + throw new Error("not implemented"); + }), + interruptTurn: vi.fn(async () => undefined), + respondToRequest: vi.fn(async () => undefined), + stopSession: vi.fn(async () => undefined), + listSessions: vi.fn(async () => []), + listCheckpoints: vi.fn(async () => { + throw new Error("not implemented"); + }), + getCheckpointDiff: vi.fn(async () => { + throw new Error("not implemented"); + }), + revertToCheckpoint: vi.fn(async () => { + throw new Error("not implemented"); + }), + onEvent: vi.fn(() => () => undefined), + }, + state: { + bootstrap: vi.fn(async () => ({ + projects: [], + threads: [], + lastStateSeq: 3, + })), + listMessages: vi.fn(async () => ({ + messages: [], + total: 0, + nextOffset: null, + })), + catchUp: vi.fn(async () => ({ + events: [], + lastStateSeq: 3, + })), + onEvent: onStateEvent, + }, + appSettings: { + get: vi.fn(async () => ({ codexBinaryPath: "", codexHomePath: "" })), + update: vi.fn(async () => ({ codexBinaryPath: "", codexHomePath: "" })), + }, + threads: { + create: vi.fn(async () => { + throw new Error("not implemented"); + }), + delete: vi.fn(async () => undefined), + markVisited: vi.fn(async () => { + throw new Error("not implemented"); + }), + updateTerminalState: vi.fn(async () => { + throw new Error("not implemented"); + }), + updateModel: vi.fn(async () => { + throw new Error("not implemented"); + }), + updateTitle: vi.fn(async () => { + throw new Error("not implemented"); + }), + updateBranch: vi.fn(async () => { + throw new Error("not implemented"); + }), + }, + projects: { + list: vi.fn(async () => []), + add: vi.fn(async () => { + throw new Error("not implemented"); + }), + remove: vi.fn(async () => undefined), + searchEntries: vi.fn(async () => ({ entries: [], truncated: false })), + updateScripts: vi.fn(async () => { + throw new Error("not implemented"); + }), + }, + shell: { + openInEditor: vi.fn(async () => undefined), + openExternal: vi.fn(async () => undefined), + }, + git: { + pull: vi.fn(async () => { + throw new Error("not implemented"); + }), + status: vi.fn(async () => { + throw new Error("not implemented"); + }), + runStackedAction: vi.fn(async () => { + throw new Error("not implemented"); + }), + listBranches: vi.fn(async () => { + throw new Error("not implemented"); + }), + createWorktree: vi.fn(async () => { + throw new Error("not implemented"); + }), + removeWorktree: vi.fn(async () => undefined), + createBranch: vi.fn(async () => undefined), + checkout: vi.fn(async () => undefined), + init: vi.fn(async () => undefined), + }, + contextMenu: { + show: vi.fn(async () => null), + }, + server: { + getConfig: vi.fn(async () => ({ + cwd: "/workspace", + keybindings: [], + })), + upsertKeybinding: vi.fn(async () => ({ + keybindings: [], + })), + }, + }; +} + +describe("createStateSource", () => { + it("delegates bootstrap/catchUp/onEvent to api.state", async () => { + const api = makeApi(); + const source = createStateSource(api); + + await expect(source.bootstrap()).resolves.toEqual({ + projects: [], + threads: [], + lastStateSeq: 3, + }); + await expect(source.catchUp({ afterSeq: 1 })).resolves.toEqual({ + events: [], + lastStateSeq: 3, + }); + + const received: StateEvent[] = []; + const unsubscribe = source.onEvent((event) => { + received.push(event); + }); + expect(received).toEqual([ + { + seq: 1, + eventType: "project.upsert", + entityId: "project-1", + payload: {}, + createdAt: "2026-02-20T00:00:00.000Z", + }, + ]); + unsubscribe(); + + expect(api.state.bootstrap).toHaveBeenCalledTimes(1); + expect(api.state.catchUp).toHaveBeenCalledWith({ afterSeq: 1 }); + expect(api.state.onEvent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/stateSource.ts b/apps/web/src/stateSource.ts new file mode 100644 index 000000000000..ed541d74a635 --- /dev/null +++ b/apps/web/src/stateSource.ts @@ -0,0 +1,21 @@ +import type { + NativeApi, + StateBootstrapResult, + StateCatchUpInput, + StateCatchUpResult, + StateEvent, +} from "@t3tools/contracts"; + +export interface StateSource { + bootstrap(): Promise; + catchUp(input: StateCatchUpInput): Promise; + onEvent(listener: (event: StateEvent) => void): () => void; +} + +export function createStateSource(api: NativeApi): StateSource { + return { + bootstrap: () => api.state.bootstrap(), + catchUp: (input) => api.state.catchUp(input), + onEvent: (listener) => api.state.onEvent(listener), + }; +} From 38ca95a787f7146b7b5cdcff8606de552b159442 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 10:02:40 +0000 Subject: [PATCH 08/42] Add bootstrap parity diagnostics for LiveStore read pilot Co-authored-by: Julius Marminge --- apps/server/src/index.ts | 4 ++ .../stateSyncEngineLiveStoreReadPilot.test.ts | 43 +++++++++++++++++++ .../src/stateSyncEngineLiveStoreReadPilot.ts | 37 ++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 0e19f1f7935c..8abae986d965 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -133,6 +133,8 @@ async function main() { const legacyStateDir = resolveStateDir(process.env.T3CODE_STATE_DIR); const stateDbPath = resolveStateDbPath(process.env.T3CODE_STATE_DB_PATH); const syncEngineMode = resolveSyncEngineMode(process.env.T3CODE_SYNC_ENGINE_MODE); + const enableLiveStoreBootstrapParityCheck = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_BOOTSTRAP_PARITY_CHECK) ?? false; const persistenceService = new PersistenceService({ dbPath: stateDbPath, legacyProjectsJsonPath: path.join(legacyStateDir, "projects.json"), @@ -151,6 +153,7 @@ async function main() { ? new LiveStoreReadPilotStateSyncEngine({ delegate: legacyStateSyncEngine, mirror: liveStoreMirror, + enableBootstrapParityCheck: enableLiveStoreBootstrapParityCheck, }) : legacyStateSyncEngine; const devUrl = process.env.VITE_DEV_SERVER_URL; @@ -184,6 +187,7 @@ async function main() { stateDbPath, legacyStateDir, syncEngineMode, + liveStoreBootstrapParityCheck: enableLiveStoreBootstrapParityCheck, authEnabled: Boolean(authToken), }); diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts index b186f73bb7c4..e32a90e982c3 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts @@ -262,4 +262,47 @@ describe("LiveStoreReadPilotStateSyncEngine", () => { engine.close(); } }); + + it("runs bootstrap parity check against delegate when enabled", () => { + const delegate = new MockDelegateStateSyncEngine(); + delegate.loadSnapshotMock.mockReturnValue({ + projects: [], + threads: [], + lastStateSeq: 9, + }); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 5, + }, + catchUp: { + events: [], + lastStateSeq: 5, + }, + listMessages: { + messages: [], + total: 0, + nextOffset: null, + }, + }); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + enableBootstrapParityCheck: true, + }); + + try { + expect(engine.loadSnapshot()).toEqual({ + projects: [], + threads: [], + lastStateSeq: 5, + }); + // One call from the parity check path while mirror remains source. + expect(delegate.loadSnapshotMock).toHaveBeenCalledTimes(1); + } finally { + engine.close(); + } + }); }); diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts index 0eee80a6234b..f73d11fe5f8f 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts @@ -25,6 +25,7 @@ import type { } from "@t3tools/contracts"; import { createLogger } from "./logger"; import type { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; +import { diffStateSnapshots } from "./livestore/parity"; import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; interface LiveStoreReadPilotEvents { @@ -34,6 +35,7 @@ interface LiveStoreReadPilotEvents { export interface LiveStoreReadPilotStateSyncEngineOptions { delegate: StateSyncEngine; mirror: LiveStoreStateMirror; + enableBootstrapParityCheck?: boolean; } export class LiveStoreReadPilotStateSyncEngine @@ -44,13 +46,16 @@ export class LiveStoreReadPilotStateSyncEngine private readonly mirror: LiveStoreStateMirror; private readonly logger = createLogger("livestore-read-pilot"); private readonly unsubscribeDelegate: () => void; + private readonly enableBootstrapParityCheck: boolean; private closed = false; private bootstrapSource: "delegate" | "livestore" = "delegate"; + private bootstrapParityState: "unknown" | "in-parity" | "drift" = "unknown"; constructor(options: LiveStoreReadPilotStateSyncEngineOptions) { super(); this.delegate = options.delegate; this.mirror = options.mirror; + this.enableBootstrapParityCheck = options.enableBootstrapParityCheck ?? false; this.unsubscribeDelegate = this.delegate.onStateEvent((event) => { this.emit("stateEvent", event); void this.mirror.mirrorStateEvent(event).catch((error) => { @@ -73,6 +78,9 @@ export class LiveStoreReadPilotStateSyncEngine try { const snapshot = this.mirror.debugReadSnapshot(); if (snapshot.lastStateSeq > 0) { + if (this.enableBootstrapParityCheck) { + this.checkBootstrapParity(snapshot); + } if (this.bootstrapSource !== "livestore") { this.bootstrapSource = "livestore"; this.logger.info("serving state.bootstrap from livestore mirror", { @@ -91,6 +99,35 @@ export class LiveStoreReadPilotStateSyncEngine return this.delegate.loadSnapshot(); } + private checkBootstrapParity(mirrorSnapshot: StateBootstrapResult): void { + let delegateSnapshot: StateBootstrapResult; + try { + delegateSnapshot = this.delegate.loadSnapshot(); + } catch (error) { + this.logger.warn("bootstrap parity check failed to read delegate snapshot", { error }); + return; + } + + const diffs = diffStateSnapshots(delegateSnapshot, mirrorSnapshot); + if (diffs.length === 0) { + if (this.bootstrapParityState !== "in-parity") { + this.bootstrapParityState = "in-parity"; + this.logger.info("livestore read pilot bootstrap parity check passed", { + lastStateSeq: mirrorSnapshot.lastStateSeq, + }); + } + return; + } + + this.bootstrapParityState = "drift"; + this.logger.warn("livestore read pilot bootstrap parity drift detected", { + diffCount: diffs.length, + samplePaths: diffs.slice(0, 5).map((diff) => diff.path), + delegateLastStateSeq: delegateSnapshot.lastStateSeq, + mirrorLastStateSeq: mirrorSnapshot.lastStateSeq, + }); + } + listMessages(raw: StateListMessagesInput): StateListMessagesResult { try { return this.mirror.debugListMessages(raw); From 2e2af4cb46cbe7e5dd2b4cb88cf1c6b40dc00972 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 10:03:28 +0000 Subject: [PATCH 09/42] Document sync engine migration runtime flags Co-authored-by: Julius Marminge --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 425a70e40410..0195c4d4adab 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,23 @@ T3 Code has a global runtime mode switch in the chat toolbar: - **Full access** (default): starts sessions with `approvalPolicy: never` and `sandboxMode: danger-full-access`. - **Supervised**: starts sessions with `approvalPolicy: on-request` and `sandboxMode: workspace-write`, then prompts in-app for command/file approvals. +## Sync engine migration modes + +The server supports feature-flagged sync-engine modes while migrating from the legacy in-house state sync pipeline to LiveStore. + +- `T3CODE_SYNC_ENGINE_MODE=legacy` (default) + - Uses the existing `PersistenceService`-backed state sync engine for reads and writes. +- `T3CODE_SYNC_ENGINE_MODE=shadow` + - Keeps legacy state as canonical, but mirrors committed `state.event` traffic into a LiveStore shadow store for parity validation. +- `T3CODE_SYNC_ENGINE_MODE=livestore-read-pilot` + - Uses the LiveStore mirror for `state.bootstrap`, `state.catchUp`, and `state.listMessages` reads when available. + - Automatically falls back to legacy reads if the mirror is unavailable or errors. + +Optional diagnostics: + +- `T3CODE_LIVESTORE_BOOTSTRAP_PARITY_CHECK=1` + - In `livestore-read-pilot` mode, compares LiveStore vs legacy `state.bootstrap` snapshots and logs drift diagnostics. + ## Provider architecture The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol: From 89e2c73f1f12ae137f80eae01bf84f41130b5724 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 10:05:46 +0000 Subject: [PATCH 10/42] Add shadow engine parity integration coverage Co-authored-by: Julius Marminge --- apps/server/src/stateSyncEngineShadow.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/apps/server/src/stateSyncEngineShadow.test.ts b/apps/server/src/stateSyncEngineShadow.test.ts index 2f3f6939be88..4fc45e9abfdc 100644 --- a/apps/server/src/stateSyncEngineShadow.test.ts +++ b/apps/server/src/stateSyncEngineShadow.test.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { StateEvent } from "@t3tools/contracts"; +import { diffStateSnapshots } from "./livestore/parity"; +import { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; import { PersistenceService } from "./persistenceService"; import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; import type { StateEventMirror } from "./stateSyncEngineShadow"; @@ -17,6 +19,21 @@ function makeTempDir(prefix: string): string { return dir; } +async function waitForParity( + getExpected: () => ReturnType, + getMirrored: () => ReturnType, +): Promise { + const maxAttempts = 30; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + if (diffStateSnapshots(getExpected(), getMirrored()).length === 0) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 15)); + } + + expect(diffStateSnapshots(getExpected(), getMirrored())).toEqual([]); +} + class CapturingMirror implements StateEventMirror { readonly events: StateEvent[] = []; disposeCalls = 0; @@ -125,4 +142,39 @@ describe("ShadowStateSyncEngine", () => { service.close(); } }); + + it("keeps LiveStore mirror snapshot in parity with delegate writes", async () => { + const stateDir = makeTempDir("t3code-shadow-parity-state-"); + const projectDir = makeTempDir("t3code-shadow-parity-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const mirror = new LiveStoreStateMirror({ storeId: "shadow-parity-engine-test" }); + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + const project = shadow.addProject({ cwd: projectDir }).project; + const thread = shadow.createThread({ + projectId: project.id, + title: "Parity thread", + }).thread; + service.bindSessionToThread("parity-session", thread.id, "runtime-thread-parity"); + service.persistUserMessageForTurn({ + sessionId: "parity-session", + clientMessageId: "parity-message-1", + clientMessageText: "hello parity", + input: "hello parity", + attachments: [], + }); + + await waitForParity(() => legacy.loadSnapshot(), () => mirror.debugReadSnapshot()); + } finally { + shadow.close(); + service.close(); + } + }); }); From fc14fdb569d60088a17dc418a5eebfd2426a1964 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 10:19:38 +0000 Subject: [PATCH 11/42] Expand shadow parity coverage in persistence and ws tests Co-authored-by: Julius Marminge --- apps/server/src/persistenceService.test.ts | 92 ++++++++++++++++++++++ apps/server/src/wsServer.test.ts | 77 ++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/apps/server/src/persistenceService.test.ts b/apps/server/src/persistenceService.test.ts index 88fd74444a4c..bee57a7e78a5 100644 --- a/apps/server/src/persistenceService.test.ts +++ b/apps/server/src/persistenceService.test.ts @@ -5,7 +5,11 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { ProviderEvent } from "@t3tools/contracts"; +import { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; +import { diffStateSnapshots } from "./livestore/parity"; import { PersistenceService } from "./persistenceService"; +import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; +import { ShadowStateSyncEngine } from "./stateSyncEngineShadow"; const tempDirs: string[] = []; const ISO_BASE_MS = Date.parse("2026-02-19T00:00:00.000Z"); @@ -20,6 +24,20 @@ function iso(offsetMs = 0): string { return new Date(ISO_BASE_MS + offsetMs).toISOString(); } +async function waitForMirrorParity( + readExpected: () => ReturnType, + readMirror: () => ReturnType, +): Promise { + const maxAttempts = 40; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + if (diffStateSnapshots(readExpected(), readMirror()).length === 0) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 15)); + } + expect(diffStateSnapshots(readExpected(), readMirror())).toEqual([]); +} + afterEach(() => { for (const dir of tempDirs.splice(0, tempDirs.length)) { fs.rmSync(dir, { recursive: true, force: true }); @@ -446,4 +464,78 @@ describe("PersistenceService", () => { } }); + it("maintains shadow mirror parity across checkpoint revert effects", async () => { + const stateDir = makeTempDir("t3code-persistence-shadow-parity-state-"); + const projectDir = makeTempDir("t3code-persistence-shadow-parity-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const mirror = new LiveStoreStateMirror({ storeId: "persistence-shadow-parity-test" }); + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + const project = shadow.addProject({ cwd: projectDir }).project; + const thread = shadow.createThread({ + projectId: project.id, + title: "checkpoint parity thread", + model: "gpt-5.3-codex", + }).thread; + service.bindSessionToThread("sess-shadow", thread.id, "runtime-thread-shadow"); + + service.persistUserMessageForTurn({ + sessionId: "sess-shadow", + clientMessageId: "msg-1", + clientMessageText: "first", + input: "first", + attachments: [], + }); + service.persistUserMessageForTurn({ + sessionId: "sess-shadow", + clientMessageId: "msg-2", + clientMessageText: "second", + input: "second", + attachments: [], + }); + + service.persistTurnDiffSummaryFromCheckpoint({ + sessionId: "sess-shadow", + runtimeThreadId: "runtime-thread-shadow", + turnId: "turn-1", + checkpointTurnCount: 1, + completedAt: iso(100), + diff: "", + }); + service.persistTurnDiffSummaryFromCheckpoint({ + sessionId: "sess-shadow", + runtimeThreadId: "runtime-thread-shadow", + turnId: "turn-2", + checkpointTurnCount: 2, + completedAt: iso(200), + diff: "", + }); + + shadow.applyCheckpointRevert({ + sessionId: "sess-shadow", + runtimeThreadId: "runtime-thread-shadow", + turnCount: 1, + messageCount: 1, + }); + + await waitForMirrorParity(() => legacy.loadSnapshot(), () => mirror.debugReadSnapshot()); + + const mirrored = mirror.debugReadSnapshot(); + expect(mirrored.threads[0]?.messages.map((message) => message.id)).toEqual(["msg-1"]); + expect(mirrored.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ + "turn-1", + ]); + expect(diffStateSnapshots(legacy.loadSnapshot(), mirrored)).toEqual([]); + } finally { + shadow.close(); + } + }); + }); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 6979785e0844..6f08807f7cd9 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -44,6 +44,10 @@ import type { TerminalManager } from "./terminalManager"; import { PersistenceService } from "./persistenceService"; import { ProviderManager } from "./providerManager"; import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; +import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; +import { ShadowStateSyncEngine } from "./stateSyncEngineShadow"; +import { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; +import { diffStateSnapshots } from "./livestore/parity"; interface PendingMessages { queue: unknown[]; @@ -383,6 +387,22 @@ async function waitForPush(ws: WebSocket, channel: string): Promise { } } +async function waitForCondition( + predicate: () => boolean, + options: { timeoutMs?: number; intervalMs?: number } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 1500; + const intervalMs = options.intervalMs ?? 20; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + expect(predicate()).toBe(true); +} + const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybindingsConfig { @@ -970,6 +990,63 @@ describe("WebSocket Server", () => { expect(pushed.data).toEqual(mirroredStateEvent); }); + it("keeps a shadow sync engine mirror in parity for websocket mutations", async () => { + const stateDir = makeTempDir("t3code-ws-shadow-engine-state-"); + const projectCwd = makeTempDir("t3code-ws-shadow-engine-project-"); + const persistenceService = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + legacyProjectsJsonPath: path.join(stateDir, "projects.json"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService }); + const mirror = new LiveStoreStateMirror({ storeId: "ws-shadow-engine-parity-test" }); + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + server = createTestServer({ + cwd: "/test", + stateSyncEngine: shadow, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const addedProject = await sendRequest(ws, WS_METHODS.projectsAdd, { cwd: projectCwd }); + expect(addedProject.error).toBeUndefined(); + const projectId = (addedProject.result as { project: { id: string } }).project.id; + + const createdThread = await sendRequest(ws, WS_METHODS.threadsCreate, { + projectId, + title: "Shadow parity thread", + model: "gpt-5.3-codex", + }); + expect(createdThread.error).toBeUndefined(); + const threadId = (createdThread.result as { thread: { id: string } }).thread.id; + + const updatedThread = await sendRequest(ws, WS_METHODS.threadsUpdateTitle, { + threadId, + title: "Shadow parity thread updated", + }); + expect(updatedThread.error).toBeUndefined(); + + await waitForCondition(() => { + const expected = legacy.loadSnapshot(); + const mirrored = mirror.debugReadSnapshot(); + return diffStateSnapshots(expected, mirrored).length === 0; + }); + + expect(diffStateSnapshots(legacy.loadSnapshot(), mirror.debugReadSnapshot())).toEqual([]); + } finally { + shadow.close(); + } + }); + it("broadcasts ordered state.event pushes", async () => { const stateDir = makeTempDir("t3code-ws-state-events-"); const projectCwd = makeTempDir("t3code-ws-state-events-project-"); From cbfe1e3a577f0af99bcef13a9520b4ed1fcb61d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 10:26:06 +0000 Subject: [PATCH 12/42] Add source-labelled read-pilot logging and recovery test Co-authored-by: Julius Marminge --- .../stateSyncEngineLiveStoreReadPilot.test.ts | 70 ++++++++++++++++++ .../src/stateSyncEngineLiveStoreReadPilot.ts | 71 ++++++++++++++----- 2 files changed, 125 insertions(+), 16 deletions(-) diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts index e32a90e982c3..54daef002f32 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts @@ -305,4 +305,74 @@ describe("LiveStoreReadPilotStateSyncEngine", () => { engine.close(); } }); + + it("returns to mirror reads after transient fallback errors", () => { + const delegate = new MockDelegateStateSyncEngine(); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 5, + }, + catchUp: { + events: [], + lastStateSeq: 5, + }, + listMessages: { + messages: [], + total: 0, + nextOffset: null, + }, + }); + vi.mocked(mirror.debugCatchUp) + .mockImplementationOnce(() => { + throw new Error("mirror catch-up failure"); + }) + .mockImplementation(() => ({ + events: [], + lastStateSeq: 5, + })); + vi.mocked(mirror.debugListMessages) + .mockImplementationOnce(() => { + throw new Error("mirror list failure"); + }) + .mockImplementation(() => ({ + messages: [], + total: 0, + nextOffset: null, + })); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + }); + + try { + expect(engine.catchUp({ afterSeq: 0 })).toEqual({ + events: [], + lastStateSeq: 1, + }); + expect(engine.listMessages({ threadId: "thread-1", offset: 0, limit: 10 })).toEqual({ + messages: [], + total: 0, + nextOffset: null, + }); + expect(delegate.catchUpMock).toHaveBeenCalledTimes(1); + expect(delegate.listMessagesMock).toHaveBeenCalledTimes(1); + + expect(engine.catchUp({ afterSeq: 0 })).toEqual({ + events: [], + lastStateSeq: 5, + }); + expect(engine.listMessages({ threadId: "thread-1", offset: 0, limit: 10 })).toEqual({ + messages: [], + total: 0, + nextOffset: null, + }); + expect(delegate.catchUpMock).toHaveBeenCalledTimes(1); + expect(delegate.listMessagesMock).toHaveBeenCalledTimes(1); + } finally { + engine.close(); + } + }); }); diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts index f73d11fe5f8f..94a816437980 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts @@ -32,6 +32,8 @@ interface LiveStoreReadPilotEvents { stateEvent: [event: StateEvent]; } +type LiveStoreReadSource = "delegate" | "livestore"; + export interface LiveStoreReadPilotStateSyncEngineOptions { delegate: StateSyncEngine; mirror: LiveStoreStateMirror; @@ -48,7 +50,9 @@ export class LiveStoreReadPilotStateSyncEngine private readonly unsubscribeDelegate: () => void; private readonly enableBootstrapParityCheck: boolean; private closed = false; - private bootstrapSource: "delegate" | "livestore" = "delegate"; + private bootstrapSource: LiveStoreReadSource = "delegate"; + private catchUpSource: LiveStoreReadSource = "delegate"; + private listMessagesSource: LiveStoreReadSource = "delegate"; private bootstrapParityState: "unknown" | "in-parity" | "drift" = "unknown"; constructor(options: LiveStoreReadPilotStateSyncEngineOptions) { @@ -81,22 +85,21 @@ export class LiveStoreReadPilotStateSyncEngine if (this.enableBootstrapParityCheck) { this.checkBootstrapParity(snapshot); } - if (this.bootstrapSource !== "livestore") { - this.bootstrapSource = "livestore"; - this.logger.info("serving state.bootstrap from livestore mirror", { - lastStateSeq: snapshot.lastStateSeq, - }); - } + this.logReadSourceChange("state.bootstrap", this.bootstrapSource, "livestore", { + lastStateSeq: snapshot.lastStateSeq, + }); + this.bootstrapSource = "livestore"; return snapshot; } } catch (error) { this.logger.warn("failed to read bootstrap from livestore mirror; using delegate", { error }); } - if (this.bootstrapSource !== "delegate") { - this.bootstrapSource = "delegate"; - this.logger.info("serving state.bootstrap from delegate fallback"); - } - return this.delegate.loadSnapshot(); + const snapshot = this.delegate.loadSnapshot(); + this.logReadSourceChange("state.bootstrap", this.bootstrapSource, "delegate", { + lastStateSeq: snapshot.lastStateSeq, + }); + this.bootstrapSource = "delegate"; + return snapshot; } private checkBootstrapParity(mirrorSnapshot: StateBootstrapResult): void { @@ -130,29 +133,65 @@ export class LiveStoreReadPilotStateSyncEngine listMessages(raw: StateListMessagesInput): StateListMessagesResult { try { - return this.mirror.debugListMessages(raw); + const result = this.mirror.debugListMessages(raw); + this.logReadSourceChange("state.listMessages", this.listMessagesSource, "livestore", { + threadId: raw.threadId, + }); + this.listMessagesSource = "livestore"; + return result; } catch (error) { this.logger.warn("failed to list messages from livestore mirror; using delegate", { error, threadId: raw.threadId, }); - return this.delegate.listMessages(raw); + const result = this.delegate.listMessages(raw); + this.logReadSourceChange("state.listMessages", this.listMessagesSource, "delegate", { + threadId: raw.threadId, + }); + this.listMessagesSource = "delegate"; + return result; } } catchUp(raw: StateCatchUpInput): StateCatchUpResult { const afterSeq = raw.afterSeq ?? 0; try { - return this.mirror.debugCatchUp(afterSeq); + const result = this.mirror.debugCatchUp(afterSeq); + this.logReadSourceChange("state.catchUp", this.catchUpSource, "livestore", { + afterSeq, + }); + this.catchUpSource = "livestore"; + return result; } catch (error) { this.logger.warn("failed to catch up from livestore mirror; using delegate", { error, afterSeq, }); - return this.delegate.catchUp(raw); + const result = this.delegate.catchUp(raw); + this.logReadSourceChange("state.catchUp", this.catchUpSource, "delegate", { + afterSeq, + }); + this.catchUpSource = "delegate"; + return result; } } + private logReadSourceChange( + route: "state.bootstrap" | "state.catchUp" | "state.listMessages", + previous: LiveStoreReadSource, + next: LiveStoreReadSource, + metadata: Record, + ): void { + if (previous === next) { + return; + } + this.logger.info(`serving ${route} from ${next}`, { + source: next, + previousSource: previous, + ...metadata, + }); + } + getAppSettings(): AppSettings { return this.delegate.getAppSettings(); } From 7a9b733368ea6b2dd08b87857a2dbcf6591a66ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 10:33:55 +0000 Subject: [PATCH 13/42] Add optional catch-up parity diagnostics for read pilot Co-authored-by: Julius Marminge --- README.md | 2 + apps/server/src/index.ts | 4 + .../stateSyncEngineLiveStoreReadPilot.test.ts | 42 ++++++++++ .../src/stateSyncEngineLiveStoreReadPilot.ts | 81 +++++++++++++++++++ 4 files changed, 129 insertions(+) diff --git a/README.md b/README.md index 0195c4d4adab..0e79171f55d8 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,8 @@ Optional diagnostics: - `T3CODE_LIVESTORE_BOOTSTRAP_PARITY_CHECK=1` - In `livestore-read-pilot` mode, compares LiveStore vs legacy `state.bootstrap` snapshots and logs drift diagnostics. +- `T3CODE_LIVESTORE_CATCHUP_PARITY_CHECK=1` + - In `livestore-read-pilot` mode, compares LiveStore vs legacy `state.catchUp` responses and logs drift diagnostics. ## Provider architecture diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 8abae986d965..7be789c49c81 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -135,6 +135,8 @@ async function main() { const syncEngineMode = resolveSyncEngineMode(process.env.T3CODE_SYNC_ENGINE_MODE); const enableLiveStoreBootstrapParityCheck = parseBooleanEnv(process.env.T3CODE_LIVESTORE_BOOTSTRAP_PARITY_CHECK) ?? false; + const enableLiveStoreCatchUpParityCheck = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_CATCHUP_PARITY_CHECK) ?? false; const persistenceService = new PersistenceService({ dbPath: stateDbPath, legacyProjectsJsonPath: path.join(legacyStateDir, "projects.json"), @@ -154,6 +156,7 @@ async function main() { delegate: legacyStateSyncEngine, mirror: liveStoreMirror, enableBootstrapParityCheck: enableLiveStoreBootstrapParityCheck, + enableCatchUpParityCheck: enableLiveStoreCatchUpParityCheck, }) : legacyStateSyncEngine; const devUrl = process.env.VITE_DEV_SERVER_URL; @@ -188,6 +191,7 @@ async function main() { legacyStateDir, syncEngineMode, liveStoreBootstrapParityCheck: enableLiveStoreBootstrapParityCheck, + liveStoreCatchUpParityCheck: enableLiveStoreCatchUpParityCheck, authEnabled: Boolean(authToken), }); diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts index 54daef002f32..4a4a50ae4152 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts @@ -306,6 +306,48 @@ describe("LiveStoreReadPilotStateSyncEngine", () => { } }); + it("runs catch-up parity check against delegate when enabled", () => { + const delegate = new MockDelegateStateSyncEngine(); + delegate.catchUpMock.mockReturnValue({ + events: [], + lastStateSeq: 11, + }); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 5, + }, + catchUp: { + events: [], + lastStateSeq: 7, + }, + listMessages: { + messages: [], + total: 0, + nextOffset: null, + }, + }); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + enableCatchUpParityCheck: true, + }); + + try { + expect(engine.catchUp({ afterSeq: 3 })).toEqual({ + events: [], + lastStateSeq: 7, + }); + // One call from parity-check comparison path while mirror remains source. + expect(delegate.catchUpMock).toHaveBeenCalledTimes(1); + expect(delegate.catchUpMock).toHaveBeenCalledWith({ afterSeq: 3 }); + } finally { + engine.close(); + } + }); + it("returns to mirror reads after transient fallback errors", () => { const delegate = new MockDelegateStateSyncEngine(); const mirror = makeMirrorStub({ diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts index 94a816437980..3878945e0627 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts @@ -38,6 +38,7 @@ export interface LiveStoreReadPilotStateSyncEngineOptions { delegate: StateSyncEngine; mirror: LiveStoreStateMirror; enableBootstrapParityCheck?: boolean; + enableCatchUpParityCheck?: boolean; } export class LiveStoreReadPilotStateSyncEngine @@ -49,17 +50,20 @@ export class LiveStoreReadPilotStateSyncEngine private readonly logger = createLogger("livestore-read-pilot"); private readonly unsubscribeDelegate: () => void; private readonly enableBootstrapParityCheck: boolean; + private readonly enableCatchUpParityCheck: boolean; private closed = false; private bootstrapSource: LiveStoreReadSource = "delegate"; private catchUpSource: LiveStoreReadSource = "delegate"; private listMessagesSource: LiveStoreReadSource = "delegate"; private bootstrapParityState: "unknown" | "in-parity" | "drift" = "unknown"; + private catchUpParityState: "unknown" | "in-parity" | "drift" = "unknown"; constructor(options: LiveStoreReadPilotStateSyncEngineOptions) { super(); this.delegate = options.delegate; this.mirror = options.mirror; this.enableBootstrapParityCheck = options.enableBootstrapParityCheck ?? false; + this.enableCatchUpParityCheck = options.enableCatchUpParityCheck ?? false; this.unsubscribeDelegate = this.delegate.onStateEvent((event) => { this.emit("stateEvent", event); void this.mirror.mirrorStateEvent(event).catch((error) => { @@ -157,6 +161,9 @@ export class LiveStoreReadPilotStateSyncEngine const afterSeq = raw.afterSeq ?? 0; try { const result = this.mirror.debugCatchUp(afterSeq); + if (this.enableCatchUpParityCheck) { + this.checkCatchUpParity(raw, result); + } this.logReadSourceChange("state.catchUp", this.catchUpSource, "livestore", { afterSeq, }); @@ -176,6 +183,37 @@ export class LiveStoreReadPilotStateSyncEngine } } + private checkCatchUpParity(raw: StateCatchUpInput, mirrorResult: StateCatchUpResult): void { + let delegateResult: StateCatchUpResult; + try { + delegateResult = this.delegate.catchUp(raw); + } catch (error) { + this.logger.warn("catch-up parity check failed to read delegate catch-up", { error }); + return; + } + + const diffs = diffCatchUpResults(delegateResult, mirrorResult); + if (diffs.length === 0) { + if (this.catchUpParityState !== "in-parity") { + this.catchUpParityState = "in-parity"; + this.logger.info("livestore read pilot catch-up parity check passed", { + afterSeq: raw.afterSeq ?? 0, + lastStateSeq: mirrorResult.lastStateSeq, + }); + } + return; + } + + this.catchUpParityState = "drift"; + this.logger.warn("livestore read pilot catch-up parity drift detected", { + afterSeq: raw.afterSeq ?? 0, + diffCount: diffs.length, + sampleDiffs: diffs.slice(0, 5), + delegateLastStateSeq: delegateResult.lastStateSeq, + mirrorLastStateSeq: mirrorResult.lastStateSeq, + }); + } + private logReadSourceChange( route: "state.bootstrap" | "state.catchUp" | "state.listMessages", previous: LiveStoreReadSource, @@ -258,3 +296,46 @@ export class LiveStoreReadPilotStateSyncEngine void this.mirror.dispose(); } } + +function diffCatchUpResults(expected: StateCatchUpResult, actual: StateCatchUpResult): string[] { + const diffs: string[] = []; + + if (expected.lastStateSeq !== actual.lastStateSeq) { + diffs.push( + `lastStateSeq mismatch: expected=${expected.lastStateSeq} actual=${actual.lastStateSeq}`, + ); + } + + if (expected.events.length !== actual.events.length) { + diffs.push(`events.length mismatch: expected=${expected.events.length} actual=${actual.events.length}`); + } + + const minLength = Math.min(expected.events.length, actual.events.length); + for (let index = 0; index < minLength; index += 1) { + const expectedEvent = expected.events[index]; + const actualEvent = actual.events[index]; + if (!expectedEvent || !actualEvent) { + continue; + } + if (expectedEvent.seq !== actualEvent.seq) { + diffs.push(`events[${index}].seq mismatch: expected=${expectedEvent.seq} actual=${actualEvent.seq}`); + } + if (expectedEvent.eventType !== actualEvent.eventType) { + diffs.push( + `events[${index}].eventType mismatch: expected=${expectedEvent.eventType} actual=${actualEvent.eventType}`, + ); + } + if (expectedEvent.entityId !== actualEvent.entityId) { + diffs.push( + `events[${index}].entityId mismatch: expected=${expectedEvent.entityId} actual=${actualEvent.entityId}`, + ); + } + const expectedPayload = JSON.stringify(expectedEvent.payload); + const actualPayload = JSON.stringify(actualEvent.payload); + if (expectedPayload !== actualPayload) { + diffs.push(`events[${index}].payload mismatch`); + } + } + + return diffs; +} From 5001314519bd42cb6ac634a8f5fc35c4e47d274e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 10:40:06 +0000 Subject: [PATCH 14/42] Add websocket integration coverage for read-pilot ordering Co-authored-by: Julius Marminge --- apps/server/src/wsServer.test.ts | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 6f08807f7cd9..3645d851b7ab 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -45,6 +45,7 @@ import { PersistenceService } from "./persistenceService"; import { ProviderManager } from "./providerManager"; import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; +import { LiveStoreReadPilotStateSyncEngine } from "./stateSyncEngineLiveStoreReadPilot"; import { ShadowStateSyncEngine } from "./stateSyncEngineShadow"; import { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; import { diffStateSnapshots } from "./livestore/parity"; @@ -1047,6 +1048,90 @@ describe("WebSocket Server", () => { } }); + it("serves ordered bootstrap and catch-up through the read-pilot sync engine", async () => { + const stateDir = makeTempDir("t3code-ws-read-pilot-state-"); + const projectCwd = makeTempDir("t3code-ws-read-pilot-project-"); + const persistenceService = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + legacyProjectsJsonPath: path.join(stateDir, "projects.json"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService }); + const mirror = new LiveStoreStateMirror({ storeId: "ws-read-pilot-parity-test" }); + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + const readPilot = new LiveStoreReadPilotStateSyncEngine({ + delegate: shadow, + mirror, + }); + + try { + server = createTestServer({ + cwd: "/test", + stateSyncEngine: readPilot, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const addedProject = await sendRequest(ws, WS_METHODS.projectsAdd, { cwd: projectCwd }); + expect(addedProject.error).toBeUndefined(); + const projectId = (addedProject.result as { project: { id: string } }).project.id; + + const createdThread = await sendRequest(ws, WS_METHODS.threadsCreate, { + projectId, + title: "Read pilot thread", + model: "gpt-5.3-codex", + }); + expect(createdThread.error).toBeUndefined(); + const threadId = (createdThread.result as { thread: { id: string } }).thread.id; + + await sendRequest(ws, WS_METHODS.threadsUpdateTitle, { + threadId, + title: "Read pilot thread updated", + }); + + await waitForCondition(() => { + const diffs = diffStateSnapshots(legacy.loadSnapshot(), mirror.debugReadSnapshot()); + return diffs.length === 0; + }); + + const bootstrap = await sendRequest(ws, WS_METHODS.stateBootstrap); + expect(bootstrap.error).toBeUndefined(); + const snapshot = bootstrap.result as { + projects: Array<{ id: string }>; + threads: Array<{ id: string; title: string }>; + lastStateSeq: number; + }; + expect(snapshot.projects).toHaveLength(1); + expect(snapshot.threads).toHaveLength(1); + expect(snapshot.threads[0]?.title).toBe("Read pilot thread updated"); + expect(snapshot.lastStateSeq).toBeGreaterThan(0); + + const catchUp = await sendRequest(ws, WS_METHODS.stateCatchUp, { afterSeq: 0 }); + expect(catchUp.error).toBeUndefined(); + const events = (catchUp.result as { events: StateEvent[] }).events; + expect(events.length).toBeGreaterThan(0); + for (let index = 1; index < events.length; index += 1) { + const previous = events[index - 1]; + const current = events[index]; + expect(previous).toBeDefined(); + expect(current).toBeDefined(); + if (!previous || !current) continue; + expect(current.seq).toBeGreaterThan(previous.seq); + } + expect(events.some((event) => event.eventType === "project.upsert")).toBe(true); + expect(events.some((event) => event.eventType === "thread.upsert")).toBe(true); + } finally { + readPilot.close(); + } + }); + it("broadcasts ordered state.event pushes", async () => { const stateDir = makeTempDir("t3code-ws-state-events-"); const projectCwd = makeTempDir("t3code-ws-state-events-project-"); From 6513750a9cd801a420aceedebdc4adf44f6de5d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 10:44:43 +0000 Subject: [PATCH 15/42] Add websocket coverage for read-pilot fallback to legacy Co-authored-by: Julius Marminge --- apps/server/src/wsServer.test.ts | 96 ++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 3645d851b7ab..6fe802565548 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1132,6 +1132,102 @@ describe("WebSocket Server", () => { } }); + it("falls back to legacy reads when the read-pilot mirror fails", async () => { + const stateDir = makeTempDir("t3code-ws-read-pilot-fallback-state-"); + const projectCwd = makeTempDir("t3code-ws-read-pilot-fallback-project-"); + const persistenceService = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + legacyProjectsJsonPath: path.join(stateDir, "projects.json"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService }); + const failingMirror = { + mirrorStateEvent: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + debugReadSnapshot: vi.fn(() => { + throw new Error("mirror bootstrap failed"); + }), + debugCatchUp: vi.fn(() => { + throw new Error("mirror catch-up failed"); + }), + debugListMessages: vi.fn(() => { + throw new Error("mirror listMessages failed"); + }), + } as unknown as LiveStoreStateMirror; + const readPilot = new LiveStoreReadPilotStateSyncEngine({ + delegate: legacy, + mirror: failingMirror, + }); + + try { + server = createTestServer({ + cwd: "/test", + stateSyncEngine: readPilot, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const addedProject = await sendRequest(ws, WS_METHODS.projectsAdd, { cwd: projectCwd }); + expect(addedProject.error).toBeUndefined(); + const projectId = (addedProject.result as { project: { id: string } }).project.id; + + const createdThread = await sendRequest(ws, WS_METHODS.threadsCreate, { + projectId, + title: "Read pilot fallback thread", + model: "gpt-5.3-codex", + }); + expect(createdThread.error).toBeUndefined(); + const threadId = (createdThread.result as { thread: { id: string } }).thread.id; + + persistenceService.bindSessionToThread( + "read-pilot-fallback-session", + threadId, + "runtime-thread-fallback", + ); + persistenceService.persistUserMessageForTurn({ + sessionId: "read-pilot-fallback-session", + clientMessageId: "msg-fallback-1", + clientMessageText: "fallback message", + input: "fallback message", + attachments: [], + }); + + const bootstrap = await sendRequest(ws, WS_METHODS.stateBootstrap); + expect(bootstrap.error).toBeUndefined(); + const snapshot = bootstrap.result as { + projects: Array<{ id: string }>; + threads: Array<{ id: string; title: string }>; + lastStateSeq: number; + }; + expect(snapshot.projects).toHaveLength(1); + expect(snapshot.threads).toHaveLength(1); + expect(snapshot.threads[0]?.title).toBe("Read pilot fallback thread"); + expect(snapshot.lastStateSeq).toBeGreaterThan(0); + + const catchUp = await sendRequest(ws, WS_METHODS.stateCatchUp, { afterSeq: 0 }); + expect(catchUp.error).toBeUndefined(); + const events = (catchUp.result as { events: StateEvent[] }).events; + expect(events.length).toBeGreaterThan(0); + expect(events.some((event) => event.eventType === "project.upsert")).toBe(true); + expect(events.some((event) => event.eventType === "thread.upsert")).toBe(true); + + const listMessages = await sendRequest(ws, WS_METHODS.stateListMessages, { + threadId, + offset: 0, + limit: 10, + }); + expect(listMessages.error).toBeUndefined(); + const messages = (listMessages.result as { messages: Array<{ id: string }> }).messages; + expect(messages.map((message) => message.id)).toEqual(["msg-fallback-1"]); + } finally { + readPilot.close(); + } + }); + it("broadcasts ordered state.event pushes", async () => { const stateDir = makeTempDir("t3code-ws-state-events-"); const projectCwd = makeTempDir("t3code-ws-state-events-project-"); From 1bffbcfb22c1ea41f8e1dbc35477e9579366f92f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 10:47:04 +0000 Subject: [PATCH 16/42] Add web state-source mode seam for read-pilot Co-authored-by: Julius Marminge --- README.md | 6 ++++++ apps/web/src/routes/__root.tsx | 14 ++++++++++++-- apps/web/src/stateSource.test.ts | 22 +++++++++++++++++++++- apps/web/src/stateSource.ts | 19 ++++++++++++++++++- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0e79171f55d8..c5ca2a511ea0 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,12 @@ Optional diagnostics: - `T3CODE_LIVESTORE_CATCHUP_PARITY_CHECK=1` - In `livestore-read-pilot` mode, compares LiveStore vs legacy `state.catchUp` responses and logs drift diagnostics. +Web client state-source selection: + +- `VITE_T3CODE_STATE_SOURCE_MODE=legacy-api|livestore-read-pilot` + - Selects the web state-source adapter mode. + - Current behavior is protocol-compatible in both modes (delegates to `api.state.*`) while preserving a client-side seam for future LiveStore-native source wiring. + ## Provider architecture The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol: diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 2e62415ac81c..07555438738f 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -13,7 +13,7 @@ import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; import { isElectron } from "../env"; import { useNativeApi } from "../hooks/useNativeApi"; import { invalidateGitQueries } from "../lib/gitReactQuery"; -import { createStateSource } from "../stateSource"; +import { createStateSource, resolveStateSourceMode } from "../stateSource"; import { useStore } from "../store"; import { onServerWelcome } from "../wsNativeApi"; @@ -130,9 +130,19 @@ function StateSyncRouter() { strict: false, select: (params) => params.threadId, }); + const stateSourceMode = useMemo( + () => + resolveStateSourceMode( + (import.meta.env.VITE_T3CODE_STATE_SOURCE_MODE as string | undefined) ?? undefined, + ), + [], + ); const lastStateSeqRef = useRef(0); const stateQueueRef = useRef(Promise.resolve()); - const stateSource = useMemo(() => (api ? createStateSource(api) : null), [api]); + const stateSource = useMemo( + () => (api ? createStateSource(api, { mode: stateSourceMode }) : null), + [api, stateSourceMode], + ); useEffect(() => { if (!api || !stateSource) return; diff --git a/apps/web/src/stateSource.test.ts b/apps/web/src/stateSource.test.ts index 186c9f2c938d..6b9c5c3bc4c6 100644 --- a/apps/web/src/stateSource.test.ts +++ b/apps/web/src/stateSource.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { NativeApi, StateEvent } from "@t3tools/contracts"; -import { createStateSource } from "./stateSource"; +import { createStateSource, resolveStateSourceMode } from "./stateSource"; function makeApi(): NativeApi { const onStateEvent = vi.fn<(callback: (event: StateEvent) => void) => () => void>((callback) => { @@ -164,6 +164,7 @@ describe("createStateSource", () => { it("delegates bootstrap/catchUp/onEvent to api.state", async () => { const api = makeApi(); const source = createStateSource(api); + expect(source.mode).toBe("legacy-api"); await expect(source.bootstrap()).resolves.toEqual({ projects: [], @@ -194,4 +195,23 @@ describe("createStateSource", () => { expect(api.state.catchUp).toHaveBeenCalledWith({ afterSeq: 1 }); expect(api.state.onEvent).toHaveBeenCalledTimes(1); }); + + it("supports explicit read-pilot mode selection", async () => { + const api = makeApi(); + const source = createStateSource(api, { mode: "livestore-read-pilot" }); + expect(source.mode).toBe("livestore-read-pilot"); + await source.bootstrap(); + expect(api.state.bootstrap).toHaveBeenCalledTimes(1); + }); +}); + +describe("resolveStateSourceMode", () => { + it("defaults to legacy-api for missing/unknown values", () => { + expect(resolveStateSourceMode(undefined)).toBe("legacy-api"); + expect(resolveStateSourceMode("unknown")).toBe("legacy-api"); + }); + + it("accepts livestore-read-pilot value", () => { + expect(resolveStateSourceMode("livestore-read-pilot")).toBe("livestore-read-pilot"); + }); }); diff --git a/apps/web/src/stateSource.ts b/apps/web/src/stateSource.ts index ed541d74a635..b055d92ea044 100644 --- a/apps/web/src/stateSource.ts +++ b/apps/web/src/stateSource.ts @@ -6,14 +6,31 @@ import type { StateEvent, } from "@t3tools/contracts"; +export type StateSourceMode = "legacy-api" | "livestore-read-pilot"; + export interface StateSource { + mode: StateSourceMode; bootstrap(): Promise; catchUp(input: StateCatchUpInput): Promise; onEvent(listener: (event: StateEvent) => void): () => void; } -export function createStateSource(api: NativeApi): StateSource { +export function resolveStateSourceMode(rawMode: string | undefined): StateSourceMode { + if (rawMode === "livestore-read-pilot") { + return "livestore-read-pilot"; + } + return "legacy-api"; +} + +export function createStateSource( + api: NativeApi, + options: { + mode?: StateSourceMode; + } = {}, +): StateSource { + const mode = options.mode ?? "legacy-api"; return { + mode, bootstrap: () => api.state.bootstrap(), catchUp: (input) => api.state.catchUp(input), onEvent: (listener) => api.state.onEvent(listener), From c2e35cb01ba670fee906eb5d65886fb4b04514ac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 10:47:42 +0000 Subject: [PATCH 17/42] Add sync-engine migration rollout runbook Co-authored-by: Julius Marminge --- SYNC_ENGINE_MIGRATION.md | 59 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 SYNC_ENGINE_MIGRATION.md diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md new file mode 100644 index 000000000000..44d1d60eea01 --- /dev/null +++ b/SYNC_ENGINE_MIGRATION.md @@ -0,0 +1,59 @@ +# Sync Engine Migration Runbook + +This runbook describes how to operate and verify the LiveStore migration path safely. + +## Runtime modes + +Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: + +- `legacy` (default): legacy persistence service is canonical for reads/writes. +- `shadow`: legacy remains canonical, and `state.event` writes are mirrored into LiveStore. +- `livestore-read-pilot`: write path remains legacy-backed, but state reads prefer LiveStore mirror with delegate fallback. + +## Diagnostics flags + +- `T3CODE_LIVESTORE_BOOTSTRAP_PARITY_CHECK=1` + - In read-pilot mode, compares `state.bootstrap` mirror output against delegate output. +- `T3CODE_LIVESTORE_CATCHUP_PARITY_CHECK=1` + - In read-pilot mode, compares `state.catchUp` mirror output against delegate output. +- `VITE_T3CODE_STATE_SOURCE_MODE=legacy-api|livestore-read-pilot` + - Client-side state-source mode seam. Current implementation is protocol-compatible in both modes. + +## Suggested rollout sequence + +1. **Baseline** + - Run in `legacy`. + - Ensure no outstanding state-sync regressions in CI. +2. **Shadow validation window** + - Enable `T3CODE_SYNC_ENGINE_MODE=shadow`. + - Watch logs for mirror commit failures. + - Validate parity fixtures and targeted tests. +3. **Read pilot** + - Enable `T3CODE_SYNC_ENGINE_MODE=livestore-read-pilot`. + - Start with parity flags enabled in staging. + - Verify bootstrap/catch-up/list-message fallback behavior under induced mirror failures. +4. **Confidence window** + - Keep parity flags enabled until drift warnings are consistently absent. + - Track fallback frequency (should trend toward zero in healthy conditions). + +## Phase-5 cleanup readiness checklist + +Before removing legacy-only sync plumbing: + +- [ ] Read-pilot mode has stable production/staging behavior for a full confidence window. +- [ ] Bootstrap and catch-up parity checks show no unexplained drift. +- [ ] Fallback-to-delegate behavior has been exercised and observed as safe. +- [ ] Web client state-source mode seam is present and default behavior remains backwards-compatible. +- [ ] Parity and integration tests cover: + - [ ] project/thread/message lifecycle parity + - [ ] checkpoint revert parity + - [ ] websocket bootstrap/catch-up ordering in read-pilot + - [ ] websocket read fallback under mirror failures + +## Final cutover notes + +When promoting LiveStore path to default: + +- keep legacy fallback for at least one release window; +- remove fallback only after confirming operational metrics and parity diagnostics remain healthy; +- remove legacy-only state sync glue in a dedicated cleanup PR to keep risk isolated and reviewable. From b38812716d563b8520d0308222e77b16e60cdd47 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 10:52:41 +0000 Subject: [PATCH 18/42] Default sync engine mode to read-pilot with fallback Co-authored-by: Julius Marminge --- README.md | 8 ++++---- SYNC_ENGINE_MIGRATION.md | 4 ++-- apps/server/src/index.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c5ca2a511ea0..c99d5954cdef 100644 --- a/README.md +++ b/README.md @@ -105,13 +105,13 @@ T3 Code has a global runtime mode switch in the chat toolbar: The server supports feature-flagged sync-engine modes while migrating from the legacy in-house state sync pipeline to LiveStore. -- `T3CODE_SYNC_ENGINE_MODE=legacy` (default) +- `T3CODE_SYNC_ENGINE_MODE=livestore-read-pilot` (default) + - Uses the LiveStore mirror for `state.bootstrap`, `state.catchUp`, and `state.listMessages` reads when available. + - Automatically falls back to legacy reads if the mirror is unavailable or errors. +- `T3CODE_SYNC_ENGINE_MODE=legacy` - Uses the existing `PersistenceService`-backed state sync engine for reads and writes. - `T3CODE_SYNC_ENGINE_MODE=shadow` - Keeps legacy state as canonical, but mirrors committed `state.event` traffic into a LiveStore shadow store for parity validation. -- `T3CODE_SYNC_ENGINE_MODE=livestore-read-pilot` - - Uses the LiveStore mirror for `state.bootstrap`, `state.catchUp`, and `state.listMessages` reads when available. - - Automatically falls back to legacy reads if the mirror is unavailable or errors. Optional diagnostics: diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md index 44d1d60eea01..efd364c37770 100644 --- a/SYNC_ENGINE_MIGRATION.md +++ b/SYNC_ENGINE_MIGRATION.md @@ -6,9 +6,9 @@ This runbook describes how to operate and verify the LiveStore migration path sa Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: -- `legacy` (default): legacy persistence service is canonical for reads/writes. +- `livestore-read-pilot` (default): write path remains legacy-backed, but state reads prefer LiveStore mirror with delegate fallback. +- `legacy`: legacy persistence service is canonical for reads/writes. - `shadow`: legacy remains canonical, and `state.event` writes are mirrored into LiveStore. -- `livestore-read-pilot`: write path remains legacy-backed, but state reads prefer LiveStore mirror with delegate fallback. ## Diagnostics flags diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 7be789c49c81..78b112d5c3f5 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -41,7 +41,7 @@ function parsePort(value: string | undefined): number | undefined { function resolveSyncEngineMode(raw: string | undefined): SyncEngineMode { if (!raw || raw.trim().length === 0) { - return "legacy"; + return "livestore-read-pilot"; } const normalized = raw.trim().toLowerCase(); if ( From 09f93f37e96edd6c8c1cc8f2504bc678bd5c7c91 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 10:58:04 +0000 Subject: [PATCH 19/42] Extract and test sync engine mode resolution Co-authored-by: Julius Marminge --- apps/server/src/index.ts | 19 +------------------ apps/server/src/syncEngineMode.test.ts | 22 ++++++++++++++++++++++ apps/server/src/syncEngineMode.ts | 20 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 18 deletions(-) create mode 100644 apps/server/src/syncEngineMode.test.ts create mode 100644 apps/server/src/syncEngineMode.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 78b112d5c3f5..3045750ae7e8 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -11,6 +11,7 @@ import { PersistenceService } from "./persistenceService"; import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; import { LiveStoreReadPilotStateSyncEngine } from "./stateSyncEngineLiveStoreReadPilot"; import { ShadowStateSyncEngine } from "./stateSyncEngineShadow"; +import { resolveSyncEngineMode } from "./syncEngineMode"; import { createServer } from "./wsServer"; fixPath(); @@ -20,7 +21,6 @@ const cwd = process.cwd(); const logger = createLogger("server"); type RuntimeMode = "web" | "desktop"; -type SyncEngineMode = "legacy" | "shadow" | "livestore-read-pilot"; function parseBooleanEnv(value: string | undefined): boolean | undefined { if (value === undefined) return undefined; @@ -39,23 +39,6 @@ function parsePort(value: string | undefined): number | undefined { return parsed; } -function resolveSyncEngineMode(raw: string | undefined): SyncEngineMode { - if (!raw || raw.trim().length === 0) { - return "livestore-read-pilot"; - } - const normalized = raw.trim().toLowerCase(); - if ( - normalized === "legacy" || - normalized === "shadow" || - normalized === "livestore-read-pilot" - ) { - return normalized; - } - throw new Error( - `Invalid T3CODE_SYNC_ENGINE_MODE: ${raw}. Expected "legacy", "shadow", or "livestore-read-pilot".`, - ); -} - function expandHomePath(input: string): string { if (input === "~") return os.homedir(); if (input.startsWith("~/")) { diff --git a/apps/server/src/syncEngineMode.test.ts b/apps/server/src/syncEngineMode.test.ts new file mode 100644 index 000000000000..a0da369a0c71 --- /dev/null +++ b/apps/server/src/syncEngineMode.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { resolveSyncEngineMode } from "./syncEngineMode"; + +describe("resolveSyncEngineMode", () => { + it("defaults to livestore-read-pilot when env var is missing", () => { + expect(resolveSyncEngineMode(undefined)).toBe("livestore-read-pilot"); + expect(resolveSyncEngineMode("")).toBe("livestore-read-pilot"); + expect(resolveSyncEngineMode(" ")).toBe("livestore-read-pilot"); + }); + + it("accepts supported mode values case-insensitively", () => { + expect(resolveSyncEngineMode("legacy")).toBe("legacy"); + expect(resolveSyncEngineMode("SHADOW")).toBe("shadow"); + expect(resolveSyncEngineMode("livestore-read-pilot")).toBe("livestore-read-pilot"); + }); + + it("throws for unsupported values", () => { + expect(() => resolveSyncEngineMode("livestore")).toThrow( + /Invalid T3CODE_SYNC_ENGINE_MODE/i, + ); + }); +}); diff --git a/apps/server/src/syncEngineMode.ts b/apps/server/src/syncEngineMode.ts new file mode 100644 index 000000000000..d83139506a09 --- /dev/null +++ b/apps/server/src/syncEngineMode.ts @@ -0,0 +1,20 @@ +export type SyncEngineMode = "legacy" | "shadow" | "livestore-read-pilot"; + +const VALID_SYNC_ENGINE_MODES: ReadonlySet = new Set([ + "legacy", + "shadow", + "livestore-read-pilot", +]); + +export function resolveSyncEngineMode(raw: string | undefined): SyncEngineMode { + if (!raw || raw.trim().length === 0) { + return "livestore-read-pilot"; + } + const normalized = raw.trim().toLowerCase(); + if (VALID_SYNC_ENGINE_MODES.has(normalized as SyncEngineMode)) { + return normalized as SyncEngineMode; + } + throw new Error( + `Invalid T3CODE_SYNC_ENGINE_MODE: ${raw}. Expected "legacy", "shadow", or "livestore-read-pilot".`, + ); +} From 8a6676cbfb6e7f66c8e10130174d22b090208aee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 11:03:10 +0000 Subject: [PATCH 20/42] Add optional listMessages parity diagnostics in read pilot Co-authored-by: Julius Marminge --- README.md | 2 + SYNC_ENGINE_MIGRATION.md | 2 + apps/server/src/index.ts | 4 + .../stateSyncEngineLiveStoreReadPilot.test.ts | 48 +++++++++++ .../src/stateSyncEngineLiveStoreReadPilot.ts | 79 +++++++++++++++++++ 5 files changed, 135 insertions(+) diff --git a/README.md b/README.md index c99d5954cdef..35b035cd81de 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,8 @@ Optional diagnostics: - In `livestore-read-pilot` mode, compares LiveStore vs legacy `state.bootstrap` snapshots and logs drift diagnostics. - `T3CODE_LIVESTORE_CATCHUP_PARITY_CHECK=1` - In `livestore-read-pilot` mode, compares LiveStore vs legacy `state.catchUp` responses and logs drift diagnostics. +- `T3CODE_LIVESTORE_LIST_MESSAGES_PARITY_CHECK=1` + - In `livestore-read-pilot` mode, compares LiveStore vs legacy `state.listMessages` responses and logs drift diagnostics. Web client state-source selection: diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md index efd364c37770..a58f05a7a2ee 100644 --- a/SYNC_ENGINE_MIGRATION.md +++ b/SYNC_ENGINE_MIGRATION.md @@ -16,6 +16,8 @@ Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: - In read-pilot mode, compares `state.bootstrap` mirror output against delegate output. - `T3CODE_LIVESTORE_CATCHUP_PARITY_CHECK=1` - In read-pilot mode, compares `state.catchUp` mirror output against delegate output. +- `T3CODE_LIVESTORE_LIST_MESSAGES_PARITY_CHECK=1` + - In read-pilot mode, compares `state.listMessages` mirror output against delegate output. - `VITE_T3CODE_STATE_SOURCE_MODE=legacy-api|livestore-read-pilot` - Client-side state-source mode seam. Current implementation is protocol-compatible in both modes. diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 3045750ae7e8..7d6571a773b6 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -120,6 +120,8 @@ async function main() { parseBooleanEnv(process.env.T3CODE_LIVESTORE_BOOTSTRAP_PARITY_CHECK) ?? false; const enableLiveStoreCatchUpParityCheck = parseBooleanEnv(process.env.T3CODE_LIVESTORE_CATCHUP_PARITY_CHECK) ?? false; + const enableLiveStoreListMessagesParityCheck = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_LIST_MESSAGES_PARITY_CHECK) ?? false; const persistenceService = new PersistenceService({ dbPath: stateDbPath, legacyProjectsJsonPath: path.join(legacyStateDir, "projects.json"), @@ -140,6 +142,7 @@ async function main() { mirror: liveStoreMirror, enableBootstrapParityCheck: enableLiveStoreBootstrapParityCheck, enableCatchUpParityCheck: enableLiveStoreCatchUpParityCheck, + enableListMessagesParityCheck: enableLiveStoreListMessagesParityCheck, }) : legacyStateSyncEngine; const devUrl = process.env.VITE_DEV_SERVER_URL; @@ -175,6 +178,7 @@ async function main() { syncEngineMode, liveStoreBootstrapParityCheck: enableLiveStoreBootstrapParityCheck, liveStoreCatchUpParityCheck: enableLiveStoreCatchUpParityCheck, + liveStoreListMessagesParityCheck: enableLiveStoreListMessagesParityCheck, authEnabled: Boolean(authToken), }); diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts index 4a4a50ae4152..3c6d46f1fa8b 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts @@ -348,6 +348,54 @@ describe("LiveStoreReadPilotStateSyncEngine", () => { } }); + it("runs list-messages parity check against delegate when enabled", () => { + const delegate = new MockDelegateStateSyncEngine(); + delegate.listMessagesMock.mockReturnValue({ + messages: [], + total: 2, + nextOffset: 2, + }); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 5, + }, + catchUp: { + events: [], + lastStateSeq: 5, + }, + listMessages: { + messages: [], + total: 1, + nextOffset: null, + }, + }); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + enableListMessagesParityCheck: true, + }); + + try { + expect(engine.listMessages({ threadId: "thread-1", offset: 0, limit: 10 })).toEqual({ + messages: [], + total: 1, + nextOffset: null, + }); + // One call from parity-check comparison path while mirror remains source. + expect(delegate.listMessagesMock).toHaveBeenCalledTimes(1); + expect(delegate.listMessagesMock).toHaveBeenCalledWith({ + threadId: "thread-1", + offset: 0, + limit: 10, + }); + } finally { + engine.close(); + } + }); + it("returns to mirror reads after transient fallback errors", () => { const delegate = new MockDelegateStateSyncEngine(); const mirror = makeMirrorStub({ diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts index 3878945e0627..eca285d11d37 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts @@ -39,6 +39,7 @@ export interface LiveStoreReadPilotStateSyncEngineOptions { mirror: LiveStoreStateMirror; enableBootstrapParityCheck?: boolean; enableCatchUpParityCheck?: boolean; + enableListMessagesParityCheck?: boolean; } export class LiveStoreReadPilotStateSyncEngine @@ -51,12 +52,14 @@ export class LiveStoreReadPilotStateSyncEngine private readonly unsubscribeDelegate: () => void; private readonly enableBootstrapParityCheck: boolean; private readonly enableCatchUpParityCheck: boolean; + private readonly enableListMessagesParityCheck: boolean; private closed = false; private bootstrapSource: LiveStoreReadSource = "delegate"; private catchUpSource: LiveStoreReadSource = "delegate"; private listMessagesSource: LiveStoreReadSource = "delegate"; private bootstrapParityState: "unknown" | "in-parity" | "drift" = "unknown"; private catchUpParityState: "unknown" | "in-parity" | "drift" = "unknown"; + private listMessagesParityState: "unknown" | "in-parity" | "drift" = "unknown"; constructor(options: LiveStoreReadPilotStateSyncEngineOptions) { super(); @@ -64,6 +67,7 @@ export class LiveStoreReadPilotStateSyncEngine this.mirror = options.mirror; this.enableBootstrapParityCheck = options.enableBootstrapParityCheck ?? false; this.enableCatchUpParityCheck = options.enableCatchUpParityCheck ?? false; + this.enableListMessagesParityCheck = options.enableListMessagesParityCheck ?? false; this.unsubscribeDelegate = this.delegate.onStateEvent((event) => { this.emit("stateEvent", event); void this.mirror.mirrorStateEvent(event).catch((error) => { @@ -138,6 +142,9 @@ export class LiveStoreReadPilotStateSyncEngine listMessages(raw: StateListMessagesInput): StateListMessagesResult { try { const result = this.mirror.debugListMessages(raw); + if (this.enableListMessagesParityCheck) { + this.checkListMessagesParity(raw, result); + } this.logReadSourceChange("state.listMessages", this.listMessagesSource, "livestore", { threadId: raw.threadId, }); @@ -157,6 +164,41 @@ export class LiveStoreReadPilotStateSyncEngine } } + private checkListMessagesParity( + raw: StateListMessagesInput, + mirrorResult: StateListMessagesResult, + ): void { + let delegateResult: StateListMessagesResult; + try { + delegateResult = this.delegate.listMessages(raw); + } catch (error) { + this.logger.warn("list-messages parity check failed to read delegate listMessages", { error }); + return; + } + + const diffs = diffListMessagesResults(delegateResult, mirrorResult); + if (diffs.length === 0) { + if (this.listMessagesParityState !== "in-parity") { + this.listMessagesParityState = "in-parity"; + this.logger.info("livestore read pilot list-messages parity check passed", { + threadId: raw.threadId, + offset: raw.offset ?? 0, + limit: raw.limit ?? 200, + }); + } + return; + } + + this.listMessagesParityState = "drift"; + this.logger.warn("livestore read pilot list-messages parity drift detected", { + threadId: raw.threadId, + offset: raw.offset ?? 0, + limit: raw.limit ?? 200, + diffCount: diffs.length, + sampleDiffs: diffs.slice(0, 5), + }); + } + catchUp(raw: StateCatchUpInput): StateCatchUpResult { const afterSeq = raw.afterSeq ?? 0; try { @@ -339,3 +381,40 @@ function diffCatchUpResults(expected: StateCatchUpResult, actual: StateCatchUpRe return diffs; } + +function diffListMessagesResults( + expected: StateListMessagesResult, + actual: StateListMessagesResult, +): string[] { + const diffs: string[] = []; + + if (expected.total !== actual.total) { + diffs.push(`total mismatch: expected=${expected.total} actual=${actual.total}`); + } + if (expected.nextOffset !== actual.nextOffset) { + diffs.push( + `nextOffset mismatch: expected=${String(expected.nextOffset)} actual=${String(actual.nextOffset)}`, + ); + } + if (expected.messages.length !== actual.messages.length) { + diffs.push( + `messages.length mismatch: expected=${expected.messages.length} actual=${actual.messages.length}`, + ); + } + + const minLength = Math.min(expected.messages.length, actual.messages.length); + for (let index = 0; index < minLength; index += 1) { + const expectedMessage = expected.messages[index]; + const actualMessage = actual.messages[index]; + if (!expectedMessage || !actualMessage) { + continue; + } + const expectedSerialized = JSON.stringify(expectedMessage); + const actualSerialized = JSON.stringify(actualMessage); + if (expectedSerialized !== actualSerialized) { + diffs.push(`messages[${index}] mismatch`); + } + } + + return diffs; +} From 6aa5a598b492e716b72f6bce0daac0bcf928d018 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 11:14:21 +0000 Subject: [PATCH 21/42] Expose sync engine mode in server config and web source selection Co-authored-by: Julius Marminge --- README.md | 1 + SYNC_ENGINE_MIGRATION.md | 4 +++- apps/server/src/index.ts | 1 + apps/server/src/wsServer.test.ts | 34 ++++++++++++++++++++++++++++++++ apps/server/src/wsServer.ts | 4 ++++ apps/web/src/routes/__root.tsx | 24 +++++++++++++--------- apps/web/src/stateSource.test.ts | 19 +++++++++++++++++- apps/web/src/stateSource.ts | 6 ++++++ packages/contracts/src/server.ts | 1 + 9 files changed, 83 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 35b035cd81de..e0c9019692f1 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ Web client state-source selection: - `VITE_T3CODE_STATE_SOURCE_MODE=legacy-api|livestore-read-pilot` - Selects the web state-source adapter mode. + - If unset, the web app derives mode from `server.getConfig().syncEngineMode` (`livestore-read-pilot` maps to read-pilot source; everything else maps to legacy-api). - Current behavior is protocol-compatible in both modes (delegates to `api.state.*`) while preserving a client-side seam for future LiveStore-native source wiring. ## Provider architecture diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md index a58f05a7a2ee..05e97e2f6924 100644 --- a/SYNC_ENGINE_MIGRATION.md +++ b/SYNC_ENGINE_MIGRATION.md @@ -19,7 +19,9 @@ Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: - `T3CODE_LIVESTORE_LIST_MESSAGES_PARITY_CHECK=1` - In read-pilot mode, compares `state.listMessages` mirror output against delegate output. - `VITE_T3CODE_STATE_SOURCE_MODE=legacy-api|livestore-read-pilot` - - Client-side state-source mode seam. Current implementation is protocol-compatible in both modes. + - Client-side state-source mode seam. + - If unset, web derives mode from `server.getConfig().syncEngineMode`. + - Current implementation is protocol-compatible in both modes. ## Suggested rollout sequence diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 7d6571a773b6..15f94d7b0942 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -164,6 +164,7 @@ async function main() { devUrl, persistenceService, stateSyncEngine, + syncEngineMode, authToken, }); await server.start(); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 6fe802565548..b4f72508093b 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -443,6 +443,7 @@ describe("WebSocket Server", () => { terminalManager?: TerminalManager; persistenceService?: PersistenceService; stateSyncEngine?: StateSyncEngine; + syncEngineMode?: "legacy" | "shadow" | "livestore-read-pilot"; } = {}, ): ReturnType { const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); @@ -460,6 +461,7 @@ describe("WebSocket Server", () => { ...(options.authToken ? { authToken: options.authToken } : {}), persistenceService, ...(options.stateSyncEngine ? { stateSyncEngine: options.stateSyncEngine } : {}), + ...(options.syncEngineMode ? { syncEngineMode: options.syncEngineMode } : {}), ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), }); @@ -551,6 +553,31 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + }); + }); + + it("includes configured sync engine mode in server.getConfig", async () => { + const fakeHome = makeTempDir("t3code-home-"); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + server = createTestServer({ + cwd: "/my/workspace", + syncEngineMode: "livestore-read-pilot", + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + syncEngineMode: "livestore-read-pilot", keybindings: DEFAULT_RESOLVED_KEYBINDINGS, }); }); @@ -584,6 +611,7 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", keybindings: mergeWithDefaultsForTest([ { key: "cmd+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, @@ -621,6 +649,7 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", keybindings: mergeWithDefaultsForTest([{ key: "mod+j", command: "terminal.toggle" }]), }); expect( @@ -656,6 +685,7 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", keybindings: DEFAULT_RESOLVED_KEYBINDINGS, }); expect( @@ -690,6 +720,7 @@ describe("WebSocket Server", () => { expect(firstResponse.error).toBeUndefined(); expect(firstResponse.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", keybindings: mergeWithDefaultsForTest([{ key: "cmd+j", command: "terminal.toggle" }]), }); @@ -750,6 +781,7 @@ describe("WebSocket Server", () => { expect(configResponse.error).toBeUndefined(); expect(configResponse.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", keybindings: mergeWithDefaultsForTest([ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+shift+r", command: "script.run-tests.run" }, @@ -783,6 +815,7 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", keybindings: DEFAULT_RESOLVED_KEYBINDINGS, }); expect( @@ -814,6 +847,7 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", + syncEngineMode: "legacy", keybindings: DEFAULT_RESOLVED_KEYBINDINGS, }); expect( diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 8d223926ef6e..53102682b49e 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -35,6 +35,7 @@ import { searchWorkspaceEntries } from "./workspaceEntries"; import { PersistenceService } from "./persistenceService"; import type { StateSyncEngine } from "./stateSyncEngine"; import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; +import type { SyncEngineMode } from "./syncEngineMode"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -64,6 +65,7 @@ export interface ServerOptions { gitManager?: GitManager | undefined; terminalManager?: TerminalManager | undefined; authToken?: string | undefined; + syncEngineMode?: SyncEngineMode | undefined; } function parseBooleanEnv(value: string | undefined): boolean | undefined { @@ -88,6 +90,7 @@ export function createServer(options: ServerOptions) { gitManager: providedGitManager, terminalManager: providedTerminalManager, authToken, + syncEngineMode = "legacy", } = options; const persistenceService = providedPersistenceService ?? @@ -523,6 +526,7 @@ export function createServer(options: ServerOptions) { case WS_METHODS.serverGetConfig: return { cwd, + syncEngineMode, keybindings: keybindingsConfig, }; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 07555438738f..86e930232a56 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -5,7 +5,7 @@ import { useParams, } from "@tanstack/react-router"; import { useEffect, useMemo, useRef } from "react"; -import { QueryClient, useQueryClient } from "@tanstack/react-query"; +import { QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { Button } from "../components/ui/button"; @@ -13,7 +13,12 @@ import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; import { isElectron } from "../env"; import { useNativeApi } from "../hooks/useNativeApi"; import { invalidateGitQueries } from "../lib/gitReactQuery"; -import { createStateSource, resolveStateSourceMode } from "../stateSource"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { + createStateSource, + mapSyncEngineModeToStateSourceMode, + resolveStateSourceMode, +} from "../stateSource"; import { useStore } from "../store"; import { onServerWelcome } from "../wsNativeApi"; @@ -130,13 +135,14 @@ function StateSyncRouter() { strict: false, select: (params) => params.threadId, }); - const stateSourceMode = useMemo( - () => - resolveStateSourceMode( - (import.meta.env.VITE_T3CODE_STATE_SOURCE_MODE as string | undefined) ?? undefined, - ), - [], - ); + const serverConfigQuery = useQuery(serverConfigQueryOptions(api)); + const stateSourceMode = useMemo(() => { + const rawOverride = import.meta.env.VITE_T3CODE_STATE_SOURCE_MODE as string | undefined; + if (rawOverride && rawOverride.trim().length > 0) { + return resolveStateSourceMode(rawOverride); + } + return mapSyncEngineModeToStateSourceMode(serverConfigQuery.data?.syncEngineMode); + }, [serverConfigQuery.data?.syncEngineMode]); const lastStateSeqRef = useRef(0); const stateQueueRef = useRef(Promise.resolve()); const stateSource = useMemo( diff --git a/apps/web/src/stateSource.test.ts b/apps/web/src/stateSource.test.ts index 6b9c5c3bc4c6..ecf26e16eda2 100644 --- a/apps/web/src/stateSource.test.ts +++ b/apps/web/src/stateSource.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import type { NativeApi, StateEvent } from "@t3tools/contracts"; -import { createStateSource, resolveStateSourceMode } from "./stateSource"; +import { + createStateSource, + mapSyncEngineModeToStateSourceMode, + resolveStateSourceMode, +} from "./stateSource"; function makeApi(): NativeApi { const onStateEvent = vi.fn<(callback: (event: StateEvent) => void) => () => void>((callback) => { @@ -151,6 +155,7 @@ function makeApi(): NativeApi { server: { getConfig: vi.fn(async () => ({ cwd: "/workspace", + syncEngineMode: "legacy" as const, keybindings: [], })), upsertKeybinding: vi.fn(async () => ({ @@ -215,3 +220,15 @@ describe("resolveStateSourceMode", () => { expect(resolveStateSourceMode("livestore-read-pilot")).toBe("livestore-read-pilot"); }); }); + +describe("mapSyncEngineModeToStateSourceMode", () => { + it("maps read-pilot server mode to read-pilot state source", () => { + expect(mapSyncEngineModeToStateSourceMode("livestore-read-pilot")).toBe("livestore-read-pilot"); + }); + + it("maps legacy/shadow/undefined server modes to legacy-api source", () => { + expect(mapSyncEngineModeToStateSourceMode("legacy")).toBe("legacy-api"); + expect(mapSyncEngineModeToStateSourceMode("shadow")).toBe("legacy-api"); + expect(mapSyncEngineModeToStateSourceMode(undefined)).toBe("legacy-api"); + }); +}); diff --git a/apps/web/src/stateSource.ts b/apps/web/src/stateSource.ts index b055d92ea044..2f4d12ca4db3 100644 --- a/apps/web/src/stateSource.ts +++ b/apps/web/src/stateSource.ts @@ -22,6 +22,12 @@ export function resolveStateSourceMode(rawMode: string | undefined): StateSource return "legacy-api"; } +export function mapSyncEngineModeToStateSourceMode( + syncEngineMode: "legacy" | "shadow" | "livestore-read-pilot" | undefined, +): StateSourceMode { + return syncEngineMode === "livestore-read-pilot" ? "livestore-read-pilot" : "legacy-api"; +} + export function createStateSource( api: NativeApi, options: { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 6303d9cfca97..1c64c7553aee 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -3,6 +3,7 @@ import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybind export const serverConfigSchema = z.object({ cwd: z.string().min(1), + syncEngineMode: z.enum(["legacy", "shadow", "livestore-read-pilot"]).default("legacy"), keybindings: resolvedKeybindingsConfigSchema.default([]), }); From 08d4dcbbb8dc96322920b6975f94e44edeec790a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 11:24:33 +0000 Subject: [PATCH 22/42] Add optional shadow bootstrap parity diagnostics Co-authored-by: Julius Marminge --- README.md | 2 + SYNC_ENGINE_MIGRATION.md | 2 + apps/server/src/index.ts | 4 ++ apps/server/src/stateSyncEngineShadow.test.ts | 36 ++++++++++++++- apps/server/src/stateSyncEngineShadow.ts | 45 ++++++++++++++++++- 5 files changed, 87 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e0c9019692f1..68dfad7b51ff 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,8 @@ Optional diagnostics: - In `livestore-read-pilot` mode, compares LiveStore vs legacy `state.catchUp` responses and logs drift diagnostics. - `T3CODE_LIVESTORE_LIST_MESSAGES_PARITY_CHECK=1` - In `livestore-read-pilot` mode, compares LiveStore vs legacy `state.listMessages` responses and logs drift diagnostics. +- `T3CODE_LIVESTORE_SHADOW_BOOTSTRAP_PARITY_CHECK=1` + - In `shadow` mode, compares mirror vs delegate `state.bootstrap` snapshots and logs drift diagnostics. Web client state-source selection: diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md index 05e97e2f6924..008c4f8e3d43 100644 --- a/SYNC_ENGINE_MIGRATION.md +++ b/SYNC_ENGINE_MIGRATION.md @@ -18,6 +18,8 @@ Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: - In read-pilot mode, compares `state.catchUp` mirror output against delegate output. - `T3CODE_LIVESTORE_LIST_MESSAGES_PARITY_CHECK=1` - In read-pilot mode, compares `state.listMessages` mirror output against delegate output. +- `T3CODE_LIVESTORE_SHADOW_BOOTSTRAP_PARITY_CHECK=1` + - In shadow mode, compares `state.bootstrap` mirror output against delegate output. - `VITE_T3CODE_STATE_SOURCE_MODE=legacy-api|livestore-read-pilot` - Client-side state-source mode seam. - If unset, web derives mode from `server.getConfig().syncEngineMode`. diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 15f94d7b0942..ce1e8225a73d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -122,6 +122,8 @@ async function main() { parseBooleanEnv(process.env.T3CODE_LIVESTORE_CATCHUP_PARITY_CHECK) ?? false; const enableLiveStoreListMessagesParityCheck = parseBooleanEnv(process.env.T3CODE_LIVESTORE_LIST_MESSAGES_PARITY_CHECK) ?? false; + const enableLiveStoreShadowBootstrapParityCheck = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_SHADOW_BOOTSTRAP_PARITY_CHECK) ?? false; const persistenceService = new PersistenceService({ dbPath: stateDbPath, legacyProjectsJsonPath: path.join(legacyStateDir, "projects.json"), @@ -135,6 +137,7 @@ async function main() { ? new ShadowStateSyncEngine({ delegate: legacyStateSyncEngine, mirror: liveStoreMirror, + enableBootstrapParityCheck: enableLiveStoreShadowBootstrapParityCheck, }) : syncEngineMode === "livestore-read-pilot" ? new LiveStoreReadPilotStateSyncEngine({ @@ -180,6 +183,7 @@ async function main() { liveStoreBootstrapParityCheck: enableLiveStoreBootstrapParityCheck, liveStoreCatchUpParityCheck: enableLiveStoreCatchUpParityCheck, liveStoreListMessagesParityCheck: enableLiveStoreListMessagesParityCheck, + liveStoreShadowBootstrapParityCheck: enableLiveStoreShadowBootstrapParityCheck, authEnabled: Boolean(authToken), }); diff --git a/apps/server/src/stateSyncEngineShadow.test.ts b/apps/server/src/stateSyncEngineShadow.test.ts index 4fc45e9abfdc..e7c16817650e 100644 --- a/apps/server/src/stateSyncEngineShadow.test.ts +++ b/apps/server/src/stateSyncEngineShadow.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { StateEvent } from "@t3tools/contracts"; import { diffStateSnapshots } from "./livestore/parity"; import { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; @@ -177,4 +177,38 @@ describe("ShadowStateSyncEngine", () => { service.close(); } }); + + it("can run optional bootstrap parity diagnostics without changing read results", () => { + const stateDir = makeTempDir("t3code-shadow-parity-diagnostics-state-"); + const projectDir = makeTempDir("t3code-shadow-parity-diagnostics-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const debugReadSnapshot = vi.fn(() => ({ + projects: [], + threads: [], + lastStateSeq: 0, + })); + const mirror: StateEventMirror = { + mirrorStateEvent: async () => undefined, + debugReadSnapshot, + dispose: async () => undefined, + }; + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + enableBootstrapParityCheck: true, + }); + + try { + legacy.addProject({ cwd: projectDir }); + const snapshot = shadow.loadSnapshot(); + expect(snapshot.projects).toHaveLength(1); + expect(debugReadSnapshot).toHaveBeenCalledTimes(1); + } finally { + shadow.close(); + service.close(); + } + }); }); diff --git a/apps/server/src/stateSyncEngineShadow.ts b/apps/server/src/stateSyncEngineShadow.ts index ca1a2085018d..76adc974b534 100644 --- a/apps/server/src/stateSyncEngineShadow.ts +++ b/apps/server/src/stateSyncEngineShadow.ts @@ -24,6 +24,7 @@ import type { ThreadsUpdateTitleInput, } from "@t3tools/contracts"; import { createLogger } from "./logger"; +import { diffStateSnapshots } from "./livestore/parity"; import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; interface StateSyncEngineShadowEvents { @@ -32,12 +33,14 @@ interface StateSyncEngineShadowEvents { export interface StateEventMirror { mirrorStateEvent(event: StateEvent): Promise; + debugReadSnapshot?(): StateBootstrapResult; dispose(): Promise | void; } export interface ShadowStateSyncEngineOptions { delegate: StateSyncEngine; mirror: StateEventMirror; + enableBootstrapParityCheck?: boolean; } export class ShadowStateSyncEngine @@ -46,14 +49,17 @@ export class ShadowStateSyncEngine { private readonly delegate: StateSyncEngine; private readonly mirror: StateEventMirror; + private readonly enableBootstrapParityCheck: boolean; private readonly logger = createLogger("shadow-sync-engine"); private readonly unsubscribeDelegate: () => void; private closed = false; + private bootstrapParityState: "unknown" | "in-parity" | "drift" = "unknown"; constructor(options: ShadowStateSyncEngineOptions) { super(); this.delegate = options.delegate; this.mirror = options.mirror; + this.enableBootstrapParityCheck = options.enableBootstrapParityCheck ?? false; this.unsubscribeDelegate = this.delegate.onStateEvent((event) => { this.emit("stateEvent", event); void this.mirror.mirrorStateEvent(event).catch((error) => { @@ -70,7 +76,44 @@ export class ShadowStateSyncEngine } loadSnapshot(): StateBootstrapResult { - return this.delegate.loadSnapshot(); + const snapshot = this.delegate.loadSnapshot(); + if (this.enableBootstrapParityCheck) { + this.checkBootstrapParity(snapshot); + } + return snapshot; + } + + private checkBootstrapParity(delegateSnapshot: StateBootstrapResult): void { + if (typeof this.mirror.debugReadSnapshot !== "function") { + return; + } + + let mirrorSnapshot: StateBootstrapResult; + try { + mirrorSnapshot = this.mirror.debugReadSnapshot(); + } catch (error) { + this.logger.warn("shadow bootstrap parity check failed to read mirror snapshot", { error }); + return; + } + + const diffs = diffStateSnapshots(delegateSnapshot, mirrorSnapshot); + if (diffs.length === 0) { + if (this.bootstrapParityState !== "in-parity") { + this.bootstrapParityState = "in-parity"; + this.logger.info("shadow bootstrap parity check passed", { + lastStateSeq: delegateSnapshot.lastStateSeq, + }); + } + return; + } + + this.bootstrapParityState = "drift"; + this.logger.warn("shadow bootstrap parity drift detected", { + diffCount: diffs.length, + samplePaths: diffs.slice(0, 5).map((diff) => diff.path), + delegateLastStateSeq: delegateSnapshot.lastStateSeq, + mirrorLastStateSeq: mirrorSnapshot.lastStateSeq, + }); } listMessages(raw: StateListMessagesInput): StateListMessagesResult { From c0c5c8f51adf07cb2771d4c749a48811a05bcfeb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 11:40:06 +0000 Subject: [PATCH 23/42] Add strict read-pilot mode without delegate fallback Co-authored-by: Julius Marminge --- README.md | 2 + SYNC_ENGINE_MIGRATION.md | 2 + apps/server/src/index.ts | 4 + .../stateSyncEngineLiveStoreReadPilot.test.ts | 85 +++++++++++++++++++ .../src/stateSyncEngineLiveStoreReadPilot.ts | 48 +++++++++-- 5 files changed, 133 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 68dfad7b51ff..658b90c38488 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,8 @@ Optional diagnostics: - In `livestore-read-pilot` mode, compares LiveStore vs legacy `state.listMessages` responses and logs drift diagnostics. - `T3CODE_LIVESTORE_SHADOW_BOOTSTRAP_PARITY_CHECK=1` - In `shadow` mode, compares mirror vs delegate `state.bootstrap` snapshots and logs drift diagnostics. +- `T3CODE_LIVESTORE_DISABLE_READ_FALLBACK=1` + - In `livestore-read-pilot` mode, disables delegate read fallback and fails requests when mirror reads fail (strict canary mode). Web client state-source selection: diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md index 008c4f8e3d43..3b60a13f4e93 100644 --- a/SYNC_ENGINE_MIGRATION.md +++ b/SYNC_ENGINE_MIGRATION.md @@ -20,6 +20,8 @@ Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: - In read-pilot mode, compares `state.listMessages` mirror output against delegate output. - `T3CODE_LIVESTORE_SHADOW_BOOTSTRAP_PARITY_CHECK=1` - In shadow mode, compares `state.bootstrap` mirror output against delegate output. +- `T3CODE_LIVESTORE_DISABLE_READ_FALLBACK=1` + - In read-pilot mode, disables delegate read fallback for strict mirror-read canary validation. - `VITE_T3CODE_STATE_SOURCE_MODE=legacy-api|livestore-read-pilot` - Client-side state-source mode seam. - If unset, web derives mode from `server.getConfig().syncEngineMode`. diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index ce1e8225a73d..3fd3e9eff2ba 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -124,6 +124,8 @@ async function main() { parseBooleanEnv(process.env.T3CODE_LIVESTORE_LIST_MESSAGES_PARITY_CHECK) ?? false; const enableLiveStoreShadowBootstrapParityCheck = parseBooleanEnv(process.env.T3CODE_LIVESTORE_SHADOW_BOOTSTRAP_PARITY_CHECK) ?? false; + const disableLiveStoreDelegateReadFallback = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_DISABLE_READ_FALLBACK) ?? false; const persistenceService = new PersistenceService({ dbPath: stateDbPath, legacyProjectsJsonPath: path.join(legacyStateDir, "projects.json"), @@ -146,6 +148,7 @@ async function main() { enableBootstrapParityCheck: enableLiveStoreBootstrapParityCheck, enableCatchUpParityCheck: enableLiveStoreCatchUpParityCheck, enableListMessagesParityCheck: enableLiveStoreListMessagesParityCheck, + disableDelegateReadFallback: disableLiveStoreDelegateReadFallback, }) : legacyStateSyncEngine; const devUrl = process.env.VITE_DEV_SERVER_URL; @@ -184,6 +187,7 @@ async function main() { liveStoreCatchUpParityCheck: enableLiveStoreCatchUpParityCheck, liveStoreListMessagesParityCheck: enableLiveStoreListMessagesParityCheck, liveStoreShadowBootstrapParityCheck: enableLiveStoreShadowBootstrapParityCheck, + liveStoreDisableReadFallback: disableLiveStoreDelegateReadFallback, authEnabled: Boolean(authToken), }); diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts index 3c6d46f1fa8b..94ae6e8f1264 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts @@ -396,6 +396,91 @@ describe("LiveStoreReadPilotStateSyncEngine", () => { } }); + it("can disable delegate read fallback for strict mirror reads", () => { + const delegate = new MockDelegateStateSyncEngine(); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 0, + }, + catchUp: { + events: [], + lastStateSeq: 0, + }, + listMessages: { + messages: [], + total: 0, + nextOffset: null, + }, + }); + vi.mocked(mirror.debugReadSnapshot).mockImplementationOnce(() => { + throw new Error("strict bootstrap failure"); + }); + vi.mocked(mirror.debugCatchUp).mockImplementationOnce(() => { + throw new Error("strict catch-up failure"); + }); + vi.mocked(mirror.debugListMessages).mockImplementationOnce(() => { + throw new Error("strict list failure"); + }); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + disableDelegateReadFallback: true, + }); + + try { + expect(() => engine.loadSnapshot()).toThrow(/strict bootstrap failure/); + expect(() => engine.catchUp({ afterSeq: 0 })).toThrow(/strict catch-up failure/); + expect(() => engine.listMessages({ threadId: "thread-1", offset: 0, limit: 10 })).toThrow( + /strict list failure/, + ); + expect(delegate.loadSnapshotMock).not.toHaveBeenCalled(); + expect(delegate.catchUpMock).not.toHaveBeenCalled(); + expect(delegate.listMessagesMock).not.toHaveBeenCalled(); + } finally { + engine.close(); + } + }); + + it("uses mirror bootstrap even when seq is zero if fallback disabled", () => { + const delegate = new MockDelegateStateSyncEngine(); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 0, + }, + catchUp: { + events: [], + lastStateSeq: 0, + }, + listMessages: { + messages: [], + total: 0, + nextOffset: null, + }, + }); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + disableDelegateReadFallback: true, + }); + + try { + expect(engine.loadSnapshot()).toEqual({ + projects: [], + threads: [], + lastStateSeq: 0, + }); + expect(delegate.loadSnapshotMock).not.toHaveBeenCalled(); + } finally { + engine.close(); + } + }); + it("returns to mirror reads after transient fallback errors", () => { const delegate = new MockDelegateStateSyncEngine(); const mirror = makeMirrorStub({ diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts index eca285d11d37..3e825c35cd06 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts @@ -40,6 +40,7 @@ export interface LiveStoreReadPilotStateSyncEngineOptions { enableBootstrapParityCheck?: boolean; enableCatchUpParityCheck?: boolean; enableListMessagesParityCheck?: boolean; + disableDelegateReadFallback?: boolean; } export class LiveStoreReadPilotStateSyncEngine @@ -53,6 +54,7 @@ export class LiveStoreReadPilotStateSyncEngine private readonly enableBootstrapParityCheck: boolean; private readonly enableCatchUpParityCheck: boolean; private readonly enableListMessagesParityCheck: boolean; + private readonly disableDelegateReadFallback: boolean; private closed = false; private bootstrapSource: LiveStoreReadSource = "delegate"; private catchUpSource: LiveStoreReadSource = "delegate"; @@ -68,6 +70,7 @@ export class LiveStoreReadPilotStateSyncEngine this.enableBootstrapParityCheck = options.enableBootstrapParityCheck ?? false; this.enableCatchUpParityCheck = options.enableCatchUpParityCheck ?? false; this.enableListMessagesParityCheck = options.enableListMessagesParityCheck ?? false; + this.disableDelegateReadFallback = options.disableDelegateReadFallback ?? false; this.unsubscribeDelegate = this.delegate.onStateEvent((event) => { this.emit("stateEvent", event); void this.mirror.mirrorStateEvent(event).catch((error) => { @@ -89,17 +92,29 @@ export class LiveStoreReadPilotStateSyncEngine loadSnapshot(): StateBootstrapResult { try { const snapshot = this.mirror.debugReadSnapshot(); - if (snapshot.lastStateSeq > 0) { - if (this.enableBootstrapParityCheck) { - this.checkBootstrapParity(snapshot); - } - this.logReadSourceChange("state.bootstrap", this.bootstrapSource, "livestore", { - lastStateSeq: snapshot.lastStateSeq, + if (this.enableBootstrapParityCheck) { + this.checkBootstrapParity(snapshot); + } + if (snapshot.lastStateSeq === 0 && !this.disableDelegateReadFallback) { + const delegateSnapshot = this.delegate.loadSnapshot(); + this.logReadSourceChange("state.bootstrap", this.bootstrapSource, "delegate", { + lastStateSeq: delegateSnapshot.lastStateSeq, }); - this.bootstrapSource = "livestore"; - return snapshot; + this.bootstrapSource = "delegate"; + return delegateSnapshot; } + this.logReadSourceChange("state.bootstrap", this.bootstrapSource, "livestore", { + lastStateSeq: snapshot.lastStateSeq, + }); + this.bootstrapSource = "livestore"; + return snapshot; } catch (error) { + if (this.disableDelegateReadFallback) { + this.logger.error("failed to read bootstrap from livestore mirror with fallback disabled", { + error, + }); + throw error; + } this.logger.warn("failed to read bootstrap from livestore mirror; using delegate", { error }); } const snapshot = this.delegate.loadSnapshot(); @@ -151,6 +166,16 @@ export class LiveStoreReadPilotStateSyncEngine this.listMessagesSource = "livestore"; return result; } catch (error) { + if (this.disableDelegateReadFallback) { + this.logger.error( + "failed to list messages from livestore mirror with fallback disabled", + { + error, + threadId: raw.threadId, + }, + ); + throw error; + } this.logger.warn("failed to list messages from livestore mirror; using delegate", { error, threadId: raw.threadId, @@ -212,6 +237,13 @@ export class LiveStoreReadPilotStateSyncEngine this.catchUpSource = "livestore"; return result; } catch (error) { + if (this.disableDelegateReadFallback) { + this.logger.error("failed to catch up from livestore mirror with fallback disabled", { + error, + afterSeq, + }); + throw error; + } this.logger.warn("failed to catch up from livestore mirror; using delegate", { error, afterSeq, From 75cdd78edb956943d61f77fd9fd8a08cb907ff1a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 11:47:34 +0000 Subject: [PATCH 24/42] Add websocket coverage for strict read-pilot error mode Co-authored-by: Julius Marminge --- apps/server/src/wsServer.test.ts | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index b4f72508093b..a2701a937114 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1262,6 +1262,78 @@ describe("WebSocket Server", () => { } }); + it("returns errors when strict read-pilot mode disables delegate fallback", async () => { + const stateDir = makeTempDir("t3code-ws-read-pilot-strict-state-"); + const projectCwd = makeTempDir("t3code-ws-read-pilot-strict-project-"); + const persistenceService = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + legacyProjectsJsonPath: path.join(stateDir, "projects.json"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService }); + const failingMirror = { + mirrorStateEvent: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + debugReadSnapshot: vi.fn(() => { + throw new Error("strict mirror bootstrap failed"); + }), + debugCatchUp: vi.fn(() => { + throw new Error("strict mirror catch-up failed"); + }), + debugListMessages: vi.fn(() => { + throw new Error("strict mirror listMessages failed"); + }), + } as unknown as LiveStoreStateMirror; + const readPilot = new LiveStoreReadPilotStateSyncEngine({ + delegate: legacy, + mirror: failingMirror, + disableDelegateReadFallback: true, + }); + + try { + server = createTestServer({ + cwd: "/test", + stateSyncEngine: readPilot, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const addedProject = await sendRequest(ws, WS_METHODS.projectsAdd, { cwd: projectCwd }); + expect(addedProject.error).toBeUndefined(); + const projectId = (addedProject.result as { project: { id: string } }).project.id; + + const createdThread = await sendRequest(ws, WS_METHODS.threadsCreate, { + projectId, + title: "Read pilot strict thread", + model: "gpt-5.3-codex", + }); + expect(createdThread.error).toBeUndefined(); + const threadId = (createdThread.result as { thread: { id: string } }).thread.id; + + const bootstrap = await sendRequest(ws, WS_METHODS.stateBootstrap); + expect(bootstrap.result).toBeUndefined(); + expect(bootstrap.error?.message).toContain("strict mirror bootstrap failed"); + + const catchUp = await sendRequest(ws, WS_METHODS.stateCatchUp, { afterSeq: 0 }); + expect(catchUp.result).toBeUndefined(); + expect(catchUp.error?.message).toContain("strict mirror catch-up failed"); + + const listMessages = await sendRequest(ws, WS_METHODS.stateListMessages, { + threadId, + offset: 0, + limit: 10, + }); + expect(listMessages.result).toBeUndefined(); + expect(listMessages.error?.message).toContain("strict mirror listMessages failed"); + } finally { + readPilot.close(); + } + }); + it("broadcasts ordered state.event pushes", async () => { const stateDir = makeTempDir("t3code-ws-state-events-"); const projectCwd = makeTempDir("t3code-ws-state-events-project-"); From 24dbfa58c9afc5331f4591c3cddf40e1ba45e416 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:03:22 +0000 Subject: [PATCH 25/42] Add optional shadow catchup and listMessages parity checks Co-authored-by: Julius Marminge --- README.md | 4 + SYNC_ENGINE_MIGRATION.md | 4 + apps/server/src/index.ts | 8 + apps/server/src/stateSyncEngineShadow.test.ts | 59 ++++++ apps/server/src/stateSyncEngineShadow.ts | 179 +++++++++++++++++- 5 files changed, 252 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 658b90c38488..b4d941f8909b 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,10 @@ Optional diagnostics: - In `livestore-read-pilot` mode, compares LiveStore vs legacy `state.listMessages` responses and logs drift diagnostics. - `T3CODE_LIVESTORE_SHADOW_BOOTSTRAP_PARITY_CHECK=1` - In `shadow` mode, compares mirror vs delegate `state.bootstrap` snapshots and logs drift diagnostics. +- `T3CODE_LIVESTORE_SHADOW_CATCHUP_PARITY_CHECK=1` + - In `shadow` mode, compares mirror vs delegate `state.catchUp` responses and logs drift diagnostics. +- `T3CODE_LIVESTORE_SHADOW_LIST_MESSAGES_PARITY_CHECK=1` + - In `shadow` mode, compares mirror vs delegate `state.listMessages` responses and logs drift diagnostics. - `T3CODE_LIVESTORE_DISABLE_READ_FALLBACK=1` - In `livestore-read-pilot` mode, disables delegate read fallback and fails requests when mirror reads fail (strict canary mode). diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md index 3b60a13f4e93..d81def92049f 100644 --- a/SYNC_ENGINE_MIGRATION.md +++ b/SYNC_ENGINE_MIGRATION.md @@ -20,6 +20,10 @@ Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: - In read-pilot mode, compares `state.listMessages` mirror output against delegate output. - `T3CODE_LIVESTORE_SHADOW_BOOTSTRAP_PARITY_CHECK=1` - In shadow mode, compares `state.bootstrap` mirror output against delegate output. +- `T3CODE_LIVESTORE_SHADOW_CATCHUP_PARITY_CHECK=1` + - In shadow mode, compares `state.catchUp` mirror output against delegate output. +- `T3CODE_LIVESTORE_SHADOW_LIST_MESSAGES_PARITY_CHECK=1` + - In shadow mode, compares `state.listMessages` mirror output against delegate output. - `T3CODE_LIVESTORE_DISABLE_READ_FALLBACK=1` - In read-pilot mode, disables delegate read fallback for strict mirror-read canary validation. - `VITE_T3CODE_STATE_SOURCE_MODE=legacy-api|livestore-read-pilot` diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 3fd3e9eff2ba..441810f1c1e1 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -124,6 +124,10 @@ async function main() { parseBooleanEnv(process.env.T3CODE_LIVESTORE_LIST_MESSAGES_PARITY_CHECK) ?? false; const enableLiveStoreShadowBootstrapParityCheck = parseBooleanEnv(process.env.T3CODE_LIVESTORE_SHADOW_BOOTSTRAP_PARITY_CHECK) ?? false; + const enableLiveStoreShadowCatchUpParityCheck = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_SHADOW_CATCHUP_PARITY_CHECK) ?? false; + const enableLiveStoreShadowListMessagesParityCheck = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_SHADOW_LIST_MESSAGES_PARITY_CHECK) ?? false; const disableLiveStoreDelegateReadFallback = parseBooleanEnv(process.env.T3CODE_LIVESTORE_DISABLE_READ_FALLBACK) ?? false; const persistenceService = new PersistenceService({ @@ -140,6 +144,8 @@ async function main() { delegate: legacyStateSyncEngine, mirror: liveStoreMirror, enableBootstrapParityCheck: enableLiveStoreShadowBootstrapParityCheck, + enableCatchUpParityCheck: enableLiveStoreShadowCatchUpParityCheck, + enableListMessagesParityCheck: enableLiveStoreShadowListMessagesParityCheck, }) : syncEngineMode === "livestore-read-pilot" ? new LiveStoreReadPilotStateSyncEngine({ @@ -187,6 +193,8 @@ async function main() { liveStoreCatchUpParityCheck: enableLiveStoreCatchUpParityCheck, liveStoreListMessagesParityCheck: enableLiveStoreListMessagesParityCheck, liveStoreShadowBootstrapParityCheck: enableLiveStoreShadowBootstrapParityCheck, + liveStoreShadowCatchUpParityCheck: enableLiveStoreShadowCatchUpParityCheck, + liveStoreShadowListMessagesParityCheck: enableLiveStoreShadowListMessagesParityCheck, liveStoreDisableReadFallback: disableLiveStoreDelegateReadFallback, authEnabled: Boolean(authToken), }); diff --git a/apps/server/src/stateSyncEngineShadow.test.ts b/apps/server/src/stateSyncEngineShadow.test.ts index e7c16817650e..0426b26761c1 100644 --- a/apps/server/src/stateSyncEngineShadow.test.ts +++ b/apps/server/src/stateSyncEngineShadow.test.ts @@ -211,4 +211,63 @@ describe("ShadowStateSyncEngine", () => { service.close(); } }); + + it("can run optional catch-up and list-messages parity diagnostics", () => { + const stateDir = makeTempDir("t3code-shadow-parity-diagnostics-reads-state-"); + const projectDir = makeTempDir("t3code-shadow-parity-diagnostics-reads-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const debugCatchUp = vi.fn((afterSeq: number) => ({ + events: [], + lastStateSeq: afterSeq, + })); + const debugListMessages = vi.fn(() => ({ + messages: [], + total: 0, + nextOffset: null, + })); + const mirror: StateEventMirror = { + mirrorStateEvent: async () => undefined, + debugCatchUp, + debugListMessages, + dispose: async () => undefined, + }; + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + enableCatchUpParityCheck: true, + enableListMessagesParityCheck: true, + }); + + try { + const project = shadow.addProject({ cwd: projectDir }).project; + const thread = shadow.createThread({ + projectId: project.id, + title: "Shadow parity read diagnostics thread", + }).thread; + + const catchUp = shadow.catchUp({ afterSeq: 0 }); + expect(catchUp.events.length).toBeGreaterThan(0); + const listed = shadow.listMessages({ threadId: thread.id, offset: 0, limit: 10 }); + expect(listed).toEqual({ + messages: [], + total: 0, + nextOffset: null, + }); + + expect(debugCatchUp).toHaveBeenCalledTimes(1); + expect(debugCatchUp).toHaveBeenCalledWith(0); + expect(debugListMessages).toHaveBeenCalledTimes(1); + expect(debugListMessages).toHaveBeenCalledWith({ + threadId: thread.id, + offset: 0, + limit: 10, + }); + } finally { + shadow.close(); + service.close(); + } + }); }); diff --git a/apps/server/src/stateSyncEngineShadow.ts b/apps/server/src/stateSyncEngineShadow.ts index 76adc974b534..fe4a2ff9f273 100644 --- a/apps/server/src/stateSyncEngineShadow.ts +++ b/apps/server/src/stateSyncEngineShadow.ts @@ -34,6 +34,8 @@ interface StateSyncEngineShadowEvents { export interface StateEventMirror { mirrorStateEvent(event: StateEvent): Promise; debugReadSnapshot?(): StateBootstrapResult; + debugCatchUp?(afterSeq: number): StateCatchUpResult; + debugListMessages?(raw: StateListMessagesInput): StateListMessagesResult; dispose(): Promise | void; } @@ -41,6 +43,8 @@ export interface ShadowStateSyncEngineOptions { delegate: StateSyncEngine; mirror: StateEventMirror; enableBootstrapParityCheck?: boolean; + enableCatchUpParityCheck?: boolean; + enableListMessagesParityCheck?: boolean; } export class ShadowStateSyncEngine @@ -50,16 +54,22 @@ export class ShadowStateSyncEngine private readonly delegate: StateSyncEngine; private readonly mirror: StateEventMirror; private readonly enableBootstrapParityCheck: boolean; + private readonly enableCatchUpParityCheck: boolean; + private readonly enableListMessagesParityCheck: boolean; private readonly logger = createLogger("shadow-sync-engine"); private readonly unsubscribeDelegate: () => void; private closed = false; private bootstrapParityState: "unknown" | "in-parity" | "drift" = "unknown"; + private catchUpParityState: "unknown" | "in-parity" | "drift" = "unknown"; + private listMessagesParityState: "unknown" | "in-parity" | "drift" = "unknown"; constructor(options: ShadowStateSyncEngineOptions) { super(); this.delegate = options.delegate; this.mirror = options.mirror; this.enableBootstrapParityCheck = options.enableBootstrapParityCheck ?? false; + this.enableCatchUpParityCheck = options.enableCatchUpParityCheck ?? false; + this.enableListMessagesParityCheck = options.enableListMessagesParityCheck ?? false; this.unsubscribeDelegate = this.delegate.onStateEvent((event) => { this.emit("stateEvent", event); void this.mirror.mirrorStateEvent(event).catch((error) => { @@ -117,11 +127,96 @@ export class ShadowStateSyncEngine } listMessages(raw: StateListMessagesInput): StateListMessagesResult { - return this.delegate.listMessages(raw); + const result = this.delegate.listMessages(raw); + if (this.enableListMessagesParityCheck) { + this.checkListMessagesParity(raw, result); + } + return result; } catchUp(raw: StateCatchUpInput): StateCatchUpResult { - return this.delegate.catchUp(raw); + const result = this.delegate.catchUp(raw); + if (this.enableCatchUpParityCheck) { + this.checkCatchUpParity(raw, result); + } + return result; + } + + private checkCatchUpParity(raw: StateCatchUpInput, delegateResult: StateCatchUpResult): void { + if (typeof this.mirror.debugCatchUp !== "function") { + return; + } + + const afterSeq = raw.afterSeq ?? 0; + let mirrorResult: StateCatchUpResult; + try { + mirrorResult = this.mirror.debugCatchUp(afterSeq); + } catch (error) { + this.logger.warn("shadow catch-up parity check failed to read mirror catch-up", { error }); + return; + } + + const diffs = diffCatchUpResults(delegateResult, mirrorResult); + if (diffs.length === 0) { + if (this.catchUpParityState !== "in-parity") { + this.catchUpParityState = "in-parity"; + this.logger.info("shadow catch-up parity check passed", { + afterSeq, + lastStateSeq: delegateResult.lastStateSeq, + }); + } + return; + } + + this.catchUpParityState = "drift"; + this.logger.warn("shadow catch-up parity drift detected", { + afterSeq, + diffCount: diffs.length, + sampleDiffs: diffs.slice(0, 5), + delegateLastStateSeq: delegateResult.lastStateSeq, + mirrorLastStateSeq: mirrorResult.lastStateSeq, + }); + } + + private checkListMessagesParity( + raw: StateListMessagesInput, + delegateResult: StateListMessagesResult, + ): void { + if (typeof this.mirror.debugListMessages !== "function") { + return; + } + + let mirrorResult: StateListMessagesResult; + try { + mirrorResult = this.mirror.debugListMessages(raw); + } catch (error) { + this.logger.warn("shadow list-messages parity check failed to read mirror listMessages", { + error, + }); + return; + } + + const diffs = diffListMessagesResults(delegateResult, mirrorResult); + if (diffs.length === 0) { + if (this.listMessagesParityState !== "in-parity") { + this.listMessagesParityState = "in-parity"; + this.logger.info("shadow list-messages parity check passed", { + threadId: raw.threadId, + offset: raw.offset ?? 0, + limit: raw.limit ?? 200, + }); + } + return; + } + + this.listMessagesParityState = "drift"; + this.logger.warn("shadow list-messages parity drift detected", { + threadId: raw.threadId, + offset: raw.offset ?? 0, + limit: raw.limit ?? 200, + diffCount: diffs.length, + sampleDiffs: diffs.slice(0, 5), + }); } getAppSettings(): AppSettings { @@ -190,3 +285,83 @@ export class ShadowStateSyncEngine void this.mirror.dispose(); } } + +function diffCatchUpResults(expected: StateCatchUpResult, actual: StateCatchUpResult): string[] { + const diffs: string[] = []; + + if (expected.lastStateSeq !== actual.lastStateSeq) { + diffs.push( + `lastStateSeq mismatch: expected=${expected.lastStateSeq} actual=${actual.lastStateSeq}`, + ); + } + + if (expected.events.length !== actual.events.length) { + diffs.push(`events.length mismatch: expected=${expected.events.length} actual=${actual.events.length}`); + } + + const minLength = Math.min(expected.events.length, actual.events.length); + for (let index = 0; index < minLength; index += 1) { + const expectedEvent = expected.events[index]; + const actualEvent = actual.events[index]; + if (!expectedEvent || !actualEvent) { + continue; + } + if (expectedEvent.seq !== actualEvent.seq) { + diffs.push(`events[${index}].seq mismatch: expected=${expectedEvent.seq} actual=${actualEvent.seq}`); + } + if (expectedEvent.eventType !== actualEvent.eventType) { + diffs.push( + `events[${index}].eventType mismatch: expected=${expectedEvent.eventType} actual=${actualEvent.eventType}`, + ); + } + if (expectedEvent.entityId !== actualEvent.entityId) { + diffs.push( + `events[${index}].entityId mismatch: expected=${expectedEvent.entityId} actual=${actualEvent.entityId}`, + ); + } + const expectedPayload = JSON.stringify(expectedEvent.payload); + const actualPayload = JSON.stringify(actualEvent.payload); + if (expectedPayload !== actualPayload) { + diffs.push(`events[${index}].payload mismatch`); + } + } + + return diffs; +} + +function diffListMessagesResults( + expected: StateListMessagesResult, + actual: StateListMessagesResult, +): string[] { + const diffs: string[] = []; + + if (expected.total !== actual.total) { + diffs.push(`total mismatch: expected=${expected.total} actual=${actual.total}`); + } + if (expected.nextOffset !== actual.nextOffset) { + diffs.push( + `nextOffset mismatch: expected=${String(expected.nextOffset)} actual=${String(actual.nextOffset)}`, + ); + } + if (expected.messages.length !== actual.messages.length) { + diffs.push( + `messages.length mismatch: expected=${expected.messages.length} actual=${actual.messages.length}`, + ); + } + + const minLength = Math.min(expected.messages.length, actual.messages.length); + for (let index = 0; index < minLength; index += 1) { + const expectedMessage = expected.messages[index]; + const actualMessage = actual.messages[index]; + if (!expectedMessage || !actualMessage) { + continue; + } + const expectedSerialized = JSON.stringify(expectedMessage); + const actualSerialized = JSON.stringify(actualMessage); + if (expectedSerialized !== actualSerialized) { + diffs.push(`messages[${index}] mismatch`); + } + } + + return diffs; +} From 04f3546c062693dc27c708576840ad37110f11db Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:06:37 +0000 Subject: [PATCH 26/42] Add contracts coverage for sync engine mode in server config Co-authored-by: Julius Marminge --- packages/contracts/src/server.test.ts | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 packages/contracts/src/server.test.ts diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts new file mode 100644 index 000000000000..1192c19696a7 --- /dev/null +++ b/packages/contracts/src/server.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { serverConfigSchema } from "./server"; + +describe("serverConfigSchema", () => { + it("defaults syncEngineMode to legacy when omitted", () => { + expect( + serverConfigSchema.parse({ + cwd: "/workspace", + keybindings: [], + }), + ).toEqual({ + cwd: "/workspace", + syncEngineMode: "legacy", + keybindings: [], + }); + }); + + it("accepts supported sync engine modes", () => { + expect( + serverConfigSchema.parse({ + cwd: "/workspace", + syncEngineMode: "livestore-read-pilot", + keybindings: [], + }).syncEngineMode, + ).toBe("livestore-read-pilot"); + + expect( + serverConfigSchema.parse({ + cwd: "/workspace", + syncEngineMode: "shadow", + keybindings: [], + }).syncEngineMode, + ).toBe("shadow"); + }); + + it("rejects unsupported sync engine mode values", () => { + expect(() => + serverConfigSchema.parse({ + cwd: "/workspace", + syncEngineMode: "livestore", + keybindings: [], + }), + ).toThrow(); + }); +}); From 3b20cc7691fe3a6aeb2d1af7cc4fae868ad2064c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:19:10 +0000 Subject: [PATCH 27/42] Add livestore strict mode and wire mode mapping across stack Co-authored-by: Julius Marminge --- README.md | 4 +++- SYNC_ENGINE_MIGRATION.md | 3 ++- apps/server/src/index.ts | 6 ++++-- apps/server/src/syncEngineMode.test.ts | 3 ++- apps/server/src/syncEngineMode.ts | 5 +++-- apps/server/src/wsServer.test.ts | 6 +++--- apps/web/src/stateSource.test.ts | 1 + apps/web/src/stateSource.ts | 6 ++++-- packages/contracts/src/server.test.ts | 10 +++++++++- packages/contracts/src/server.ts | 4 +++- 10 files changed, 34 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b4d941f8909b..e416e93569e8 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ The server supports feature-flagged sync-engine modes while migrating from the l - `T3CODE_SYNC_ENGINE_MODE=livestore-read-pilot` (default) - Uses the LiveStore mirror for `state.bootstrap`, `state.catchUp`, and `state.listMessages` reads when available. - Automatically falls back to legacy reads if the mirror is unavailable or errors. +- `T3CODE_SYNC_ENGINE_MODE=livestore` + - Uses the same LiveStore-backed read path as read-pilot, but disables delegate fallback (strict mirror-read mode). - `T3CODE_SYNC_ENGINE_MODE=legacy` - Uses the existing `PersistenceService`-backed state sync engine for reads and writes. - `T3CODE_SYNC_ENGINE_MODE=shadow` @@ -134,7 +136,7 @@ Web client state-source selection: - `VITE_T3CODE_STATE_SOURCE_MODE=legacy-api|livestore-read-pilot` - Selects the web state-source adapter mode. - - If unset, the web app derives mode from `server.getConfig().syncEngineMode` (`livestore-read-pilot` maps to read-pilot source; everything else maps to legacy-api). + - If unset, the web app derives mode from `server.getConfig().syncEngineMode` (`livestore-read-pilot`/`livestore` map to read-pilot source; everything else maps to legacy-api). - Current behavior is protocol-compatible in both modes (delegates to `api.state.*`) while preserving a client-side seam for future LiveStore-native source wiring. ## Provider architecture diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md index d81def92049f..0a736eb80b8b 100644 --- a/SYNC_ENGINE_MIGRATION.md +++ b/SYNC_ENGINE_MIGRATION.md @@ -7,6 +7,7 @@ This runbook describes how to operate and verify the LiveStore migration path sa Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: - `livestore-read-pilot` (default): write path remains legacy-backed, but state reads prefer LiveStore mirror with delegate fallback. +- `livestore`: same read path as read-pilot, but with delegate read fallback disabled (strict mirror-read mode). - `legacy`: legacy persistence service is canonical for reads/writes. - `shadow`: legacy remains canonical, and `state.event` writes are mirrored into LiveStore. @@ -28,7 +29,7 @@ Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: - In read-pilot mode, disables delegate read fallback for strict mirror-read canary validation. - `VITE_T3CODE_STATE_SOURCE_MODE=legacy-api|livestore-read-pilot` - Client-side state-source mode seam. - - If unset, web derives mode from `server.getConfig().syncEngineMode`. + - If unset, web derives mode from `server.getConfig().syncEngineMode` (`livestore-read-pilot`/`livestore` -> read-pilot source). - Current implementation is protocol-compatible in both modes. ## Suggested rollout sequence diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 441810f1c1e1..02fe515a4aa8 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -138,6 +138,7 @@ async function main() { const liveStoreMirror = new LiveStoreStateMirror({ storeId: `t3-shadow-${mode}`, }); + const isReadPilotMode = syncEngineMode === "livestore-read-pilot" || syncEngineMode === "livestore"; const stateSyncEngine = syncEngineMode === "shadow" ? new ShadowStateSyncEngine({ @@ -147,14 +148,15 @@ async function main() { enableCatchUpParityCheck: enableLiveStoreShadowCatchUpParityCheck, enableListMessagesParityCheck: enableLiveStoreShadowListMessagesParityCheck, }) - : syncEngineMode === "livestore-read-pilot" + : isReadPilotMode ? new LiveStoreReadPilotStateSyncEngine({ delegate: legacyStateSyncEngine, mirror: liveStoreMirror, enableBootstrapParityCheck: enableLiveStoreBootstrapParityCheck, enableCatchUpParityCheck: enableLiveStoreCatchUpParityCheck, enableListMessagesParityCheck: enableLiveStoreListMessagesParityCheck, - disableDelegateReadFallback: disableLiveStoreDelegateReadFallback, + disableDelegateReadFallback: + syncEngineMode === "livestore" ? true : disableLiveStoreDelegateReadFallback, }) : legacyStateSyncEngine; const devUrl = process.env.VITE_DEV_SERVER_URL; diff --git a/apps/server/src/syncEngineMode.test.ts b/apps/server/src/syncEngineMode.test.ts index a0da369a0c71..ff7834ee87ae 100644 --- a/apps/server/src/syncEngineMode.test.ts +++ b/apps/server/src/syncEngineMode.test.ts @@ -12,10 +12,11 @@ describe("resolveSyncEngineMode", () => { expect(resolveSyncEngineMode("legacy")).toBe("legacy"); expect(resolveSyncEngineMode("SHADOW")).toBe("shadow"); expect(resolveSyncEngineMode("livestore-read-pilot")).toBe("livestore-read-pilot"); + expect(resolveSyncEngineMode("LIVESTORE")).toBe("livestore"); }); it("throws for unsupported values", () => { - expect(() => resolveSyncEngineMode("livestore")).toThrow( + expect(() => resolveSyncEngineMode("something-else")).toThrow( /Invalid T3CODE_SYNC_ENGINE_MODE/i, ); }); diff --git a/apps/server/src/syncEngineMode.ts b/apps/server/src/syncEngineMode.ts index d83139506a09..d684cbe9a6a7 100644 --- a/apps/server/src/syncEngineMode.ts +++ b/apps/server/src/syncEngineMode.ts @@ -1,9 +1,10 @@ -export type SyncEngineMode = "legacy" | "shadow" | "livestore-read-pilot"; +export type SyncEngineMode = "legacy" | "shadow" | "livestore-read-pilot" | "livestore"; const VALID_SYNC_ENGINE_MODES: ReadonlySet = new Set([ "legacy", "shadow", "livestore-read-pilot", + "livestore", ]); export function resolveSyncEngineMode(raw: string | undefined): SyncEngineMode { @@ -15,6 +16,6 @@ export function resolveSyncEngineMode(raw: string | undefined): SyncEngineMode { return normalized as SyncEngineMode; } throw new Error( - `Invalid T3CODE_SYNC_ENGINE_MODE: ${raw}. Expected "legacy", "shadow", or "livestore-read-pilot".`, + `Invalid T3CODE_SYNC_ENGINE_MODE: ${raw}. Expected "legacy", "shadow", "livestore-read-pilot", or "livestore".`, ); } diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index a2701a937114..1a464c3e437d 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -443,7 +443,7 @@ describe("WebSocket Server", () => { terminalManager?: TerminalManager; persistenceService?: PersistenceService; stateSyncEngine?: StateSyncEngine; - syncEngineMode?: "legacy" | "shadow" | "livestore-read-pilot"; + syncEngineMode?: "legacy" | "shadow" | "livestore-read-pilot" | "livestore"; } = {}, ): ReturnType { const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); @@ -563,7 +563,7 @@ describe("WebSocket Server", () => { vi.spyOn(os, "homedir").mockReturnValue(fakeHome); server = createTestServer({ cwd: "/my/workspace", - syncEngineMode: "livestore-read-pilot", + syncEngineMode: "livestore", }); await server.start(); const addr = server.httpServer.address(); @@ -577,7 +577,7 @@ describe("WebSocket Server", () => { expect(response.error).toBeUndefined(); expect(response.result).toEqual({ cwd: "/my/workspace", - syncEngineMode: "livestore-read-pilot", + syncEngineMode: "livestore", keybindings: DEFAULT_RESOLVED_KEYBINDINGS, }); }); diff --git a/apps/web/src/stateSource.test.ts b/apps/web/src/stateSource.test.ts index ecf26e16eda2..be3e2d62a94e 100644 --- a/apps/web/src/stateSource.test.ts +++ b/apps/web/src/stateSource.test.ts @@ -224,6 +224,7 @@ describe("resolveStateSourceMode", () => { describe("mapSyncEngineModeToStateSourceMode", () => { it("maps read-pilot server mode to read-pilot state source", () => { expect(mapSyncEngineModeToStateSourceMode("livestore-read-pilot")).toBe("livestore-read-pilot"); + expect(mapSyncEngineModeToStateSourceMode("livestore")).toBe("livestore-read-pilot"); }); it("maps legacy/shadow/undefined server modes to legacy-api source", () => { diff --git a/apps/web/src/stateSource.ts b/apps/web/src/stateSource.ts index 2f4d12ca4db3..04f71ef54f5e 100644 --- a/apps/web/src/stateSource.ts +++ b/apps/web/src/stateSource.ts @@ -23,9 +23,11 @@ export function resolveStateSourceMode(rawMode: string | undefined): StateSource } export function mapSyncEngineModeToStateSourceMode( - syncEngineMode: "legacy" | "shadow" | "livestore-read-pilot" | undefined, + syncEngineMode: "legacy" | "shadow" | "livestore-read-pilot" | "livestore" | undefined, ): StateSourceMode { - return syncEngineMode === "livestore-read-pilot" ? "livestore-read-pilot" : "legacy-api"; + return syncEngineMode === "livestore-read-pilot" || syncEngineMode === "livestore" + ? "livestore-read-pilot" + : "legacy-api"; } export function createStateSource( diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 1192c19696a7..f193469f95ad 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -31,13 +31,21 @@ describe("serverConfigSchema", () => { keybindings: [], }).syncEngineMode, ).toBe("shadow"); + + expect( + serverConfigSchema.parse({ + cwd: "/workspace", + syncEngineMode: "livestore", + keybindings: [], + }).syncEngineMode, + ).toBe("livestore"); }); it("rejects unsupported sync engine mode values", () => { expect(() => serverConfigSchema.parse({ cwd: "/workspace", - syncEngineMode: "livestore", + syncEngineMode: "something-else", keybindings: [], }), ).toThrow(); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 1c64c7553aee..0cef20be6a21 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -3,7 +3,9 @@ import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybind export const serverConfigSchema = z.object({ cwd: z.string().min(1), - syncEngineMode: z.enum(["legacy", "shadow", "livestore-read-pilot"]).default("legacy"), + syncEngineMode: z + .enum(["legacy", "shadow", "livestore-read-pilot", "livestore"]) + .default("legacy"), keybindings: resolvedKeybindingsConfigSchema.default([]), }); From bba3afe33bdd108e9dddc548604a0ae1c4b1b632 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:23:58 +0000 Subject: [PATCH 28/42] Add read-pilot route metrics and fallback counters Co-authored-by: Julius Marminge --- README.md | 1 + SYNC_ENGINE_MIGRATION.md | 1 + .../stateSyncEngineLiveStoreReadPilot.test.ts | 48 ++++++++++++++++ .../src/stateSyncEngineLiveStoreReadPilot.ts | 56 ++++++++++++++++++- 4 files changed, 105 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e416e93569e8..26d5ba6ef93b 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ Optional diagnostics: - In `shadow` mode, compares mirror vs delegate `state.listMessages` responses and logs drift diagnostics. - `T3CODE_LIVESTORE_DISABLE_READ_FALLBACK=1` - In `livestore-read-pilot` mode, disables delegate read fallback and fails requests when mirror reads fail (strict canary mode). + - Read-source/fallback counters are emitted on shutdown via `livestore read pilot metrics` logs. Web client state-source selection: diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md index 0a736eb80b8b..f70a9acc5739 100644 --- a/SYNC_ENGINE_MIGRATION.md +++ b/SYNC_ENGINE_MIGRATION.md @@ -48,6 +48,7 @@ Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: 4. **Confidence window** - Keep parity flags enabled until drift warnings are consistently absent. - Track fallback frequency (should trend toward zero in healthy conditions). + - Review `livestore read pilot metrics` shutdown logs for route-level source and fallback counts. ## Phase-5 cleanup readiness checklist diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts index 94ae6e8f1264..f66d7238b043 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts @@ -202,6 +202,18 @@ describe("LiveStoreReadPilotStateSyncEngine", () => { expect(delegate.loadSnapshotMock).not.toHaveBeenCalled(); expect(delegate.catchUpMock).not.toHaveBeenCalled(); expect(delegate.listMessagesMock).not.toHaveBeenCalled(); + expect(engine.debugReadMetrics()).toEqual({ + routeReadCounts: { + "state.bootstrap": { livestore: 1, delegate: 0 }, + "state.catchUp": { livestore: 1, delegate: 0 }, + "state.listMessages": { livestore: 1, delegate: 0 }, + }, + fallbackCounts: { + "state.bootstrap": { "mirror-error": 0, "empty-mirror": 0 }, + "state.catchUp": { "mirror-error": 0, "empty-mirror": 0 }, + "state.listMessages": { "mirror-error": 0, "empty-mirror": 0 }, + }, + }); } finally { engine.close(); } @@ -258,6 +270,18 @@ describe("LiveStoreReadPilotStateSyncEngine", () => { expect(delegate.loadSnapshotMock).toHaveBeenCalledTimes(1); expect(delegate.catchUpMock).toHaveBeenCalledTimes(1); expect(delegate.listMessagesMock).toHaveBeenCalledTimes(1); + expect(engine.debugReadMetrics()).toEqual({ + routeReadCounts: { + "state.bootstrap": { livestore: 0, delegate: 1 }, + "state.catchUp": { livestore: 0, delegate: 1 }, + "state.listMessages": { livestore: 0, delegate: 1 }, + }, + fallbackCounts: { + "state.bootstrap": { "mirror-error": 1, "empty-mirror": 0 }, + "state.catchUp": { "mirror-error": 1, "empty-mirror": 0 }, + "state.listMessages": { "mirror-error": 1, "empty-mirror": 0 }, + }, + }); } finally { engine.close(); } @@ -476,6 +500,18 @@ describe("LiveStoreReadPilotStateSyncEngine", () => { lastStateSeq: 0, }); expect(delegate.loadSnapshotMock).not.toHaveBeenCalled(); + expect(engine.debugReadMetrics()).toEqual({ + routeReadCounts: { + "state.bootstrap": { livestore: 1, delegate: 0 }, + "state.catchUp": { livestore: 0, delegate: 0 }, + "state.listMessages": { livestore: 0, delegate: 0 }, + }, + fallbackCounts: { + "state.bootstrap": { "mirror-error": 0, "empty-mirror": 0 }, + "state.catchUp": { "mirror-error": 0, "empty-mirror": 0 }, + "state.listMessages": { "mirror-error": 0, "empty-mirror": 0 }, + }, + }); } finally { engine.close(); } @@ -546,6 +582,18 @@ describe("LiveStoreReadPilotStateSyncEngine", () => { }); expect(delegate.catchUpMock).toHaveBeenCalledTimes(1); expect(delegate.listMessagesMock).toHaveBeenCalledTimes(1); + expect(engine.debugReadMetrics()).toEqual({ + routeReadCounts: { + "state.bootstrap": { livestore: 0, delegate: 0 }, + "state.catchUp": { livestore: 1, delegate: 1 }, + "state.listMessages": { livestore: 1, delegate: 1 }, + }, + fallbackCounts: { + "state.bootstrap": { "mirror-error": 0, "empty-mirror": 0 }, + "state.catchUp": { "mirror-error": 1, "empty-mirror": 0 }, + "state.listMessages": { "mirror-error": 1, "empty-mirror": 0 }, + }, + }); } finally { engine.close(); } diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts index 3e825c35cd06..cfdc095ea291 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts @@ -33,6 +33,13 @@ interface LiveStoreReadPilotEvents { } type LiveStoreReadSource = "delegate" | "livestore"; +type LiveStoreReadRoute = "state.bootstrap" | "state.catchUp" | "state.listMessages"; +type LiveStoreFallbackReason = "mirror-error" | "empty-mirror"; + +export interface LiveStoreReadPilotMetrics { + routeReadCounts: Record; + fallbackCounts: Record; +} export interface LiveStoreReadPilotStateSyncEngineOptions { delegate: StateSyncEngine; @@ -62,6 +69,18 @@ export class LiveStoreReadPilotStateSyncEngine private bootstrapParityState: "unknown" | "in-parity" | "drift" = "unknown"; private catchUpParityState: "unknown" | "in-parity" | "drift" = "unknown"; private listMessagesParityState: "unknown" | "in-parity" | "drift" = "unknown"; + private readonly metrics: LiveStoreReadPilotMetrics = { + routeReadCounts: { + "state.bootstrap": { livestore: 0, delegate: 0 }, + "state.catchUp": { livestore: 0, delegate: 0 }, + "state.listMessages": { livestore: 0, delegate: 0 }, + }, + fallbackCounts: { + "state.bootstrap": { "mirror-error": 0, "empty-mirror": 0 }, + "state.catchUp": { "mirror-error": 0, "empty-mirror": 0 }, + "state.listMessages": { "mirror-error": 0, "empty-mirror": 0 }, + }, + }; constructor(options: LiveStoreReadPilotStateSyncEngineOptions) { super(); @@ -100,12 +119,15 @@ export class LiveStoreReadPilotStateSyncEngine this.logReadSourceChange("state.bootstrap", this.bootstrapSource, "delegate", { lastStateSeq: delegateSnapshot.lastStateSeq, }); + this.recordReadRoute("state.bootstrap", "delegate"); + this.recordFallback("state.bootstrap", "empty-mirror"); this.bootstrapSource = "delegate"; return delegateSnapshot; } this.logReadSourceChange("state.bootstrap", this.bootstrapSource, "livestore", { lastStateSeq: snapshot.lastStateSeq, }); + this.recordReadRoute("state.bootstrap", "livestore"); this.bootstrapSource = "livestore"; return snapshot; } catch (error) { @@ -116,11 +138,13 @@ export class LiveStoreReadPilotStateSyncEngine throw error; } this.logger.warn("failed to read bootstrap from livestore mirror; using delegate", { error }); + this.recordFallback("state.bootstrap", "mirror-error"); } const snapshot = this.delegate.loadSnapshot(); this.logReadSourceChange("state.bootstrap", this.bootstrapSource, "delegate", { lastStateSeq: snapshot.lastStateSeq, }); + this.recordReadRoute("state.bootstrap", "delegate"); this.bootstrapSource = "delegate"; return snapshot; } @@ -163,6 +187,7 @@ export class LiveStoreReadPilotStateSyncEngine this.logReadSourceChange("state.listMessages", this.listMessagesSource, "livestore", { threadId: raw.threadId, }); + this.recordReadRoute("state.listMessages", "livestore"); this.listMessagesSource = "livestore"; return result; } catch (error) { @@ -176,6 +201,7 @@ export class LiveStoreReadPilotStateSyncEngine ); throw error; } + this.recordFallback("state.listMessages", "mirror-error"); this.logger.warn("failed to list messages from livestore mirror; using delegate", { error, threadId: raw.threadId, @@ -184,6 +210,7 @@ export class LiveStoreReadPilotStateSyncEngine this.logReadSourceChange("state.listMessages", this.listMessagesSource, "delegate", { threadId: raw.threadId, }); + this.recordReadRoute("state.listMessages", "delegate"); this.listMessagesSource = "delegate"; return result; } @@ -234,6 +261,7 @@ export class LiveStoreReadPilotStateSyncEngine this.logReadSourceChange("state.catchUp", this.catchUpSource, "livestore", { afterSeq, }); + this.recordReadRoute("state.catchUp", "livestore"); this.catchUpSource = "livestore"; return result; } catch (error) { @@ -244,6 +272,7 @@ export class LiveStoreReadPilotStateSyncEngine }); throw error; } + this.recordFallback("state.catchUp", "mirror-error"); this.logger.warn("failed to catch up from livestore mirror; using delegate", { error, afterSeq, @@ -252,11 +281,27 @@ export class LiveStoreReadPilotStateSyncEngine this.logReadSourceChange("state.catchUp", this.catchUpSource, "delegate", { afterSeq, }); + this.recordReadRoute("state.catchUp", "delegate"); this.catchUpSource = "delegate"; return result; } } + debugReadMetrics(): LiveStoreReadPilotMetrics { + return { + routeReadCounts: { + "state.bootstrap": { ...this.metrics.routeReadCounts["state.bootstrap"] }, + "state.catchUp": { ...this.metrics.routeReadCounts["state.catchUp"] }, + "state.listMessages": { ...this.metrics.routeReadCounts["state.listMessages"] }, + }, + fallbackCounts: { + "state.bootstrap": { ...this.metrics.fallbackCounts["state.bootstrap"] }, + "state.catchUp": { ...this.metrics.fallbackCounts["state.catchUp"] }, + "state.listMessages": { ...this.metrics.fallbackCounts["state.listMessages"] }, + }, + }; + } + private checkCatchUpParity(raw: StateCatchUpInput, mirrorResult: StateCatchUpResult): void { let delegateResult: StateCatchUpResult; try { @@ -289,7 +334,7 @@ export class LiveStoreReadPilotStateSyncEngine } private logReadSourceChange( - route: "state.bootstrap" | "state.catchUp" | "state.listMessages", + route: LiveStoreReadRoute, previous: LiveStoreReadSource, next: LiveStoreReadSource, metadata: Record, @@ -304,6 +349,14 @@ export class LiveStoreReadPilotStateSyncEngine }); } + private recordReadRoute(route: LiveStoreReadRoute, source: LiveStoreReadSource): void { + this.metrics.routeReadCounts[route][source] += 1; + } + + private recordFallback(route: LiveStoreReadRoute, reason: LiveStoreFallbackReason): void { + this.metrics.fallbackCounts[route][reason] += 1; + } + getAppSettings(): AppSettings { return this.delegate.getAppSettings(); } @@ -365,6 +418,7 @@ export class LiveStoreReadPilotStateSyncEngine return; } this.closed = true; + this.logger.info("livestore read pilot metrics", { metrics: this.metrics }); this.unsubscribeDelegate(); this.removeAllListeners(); void this.mirror.dispose(); From 8b8ce91e377c9d52c4f39fe5813c7770dd85185b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:26:22 +0000 Subject: [PATCH 29/42] Infer sync engine mode from injected state engine Co-authored-by: Julius Marminge --- apps/server/src/wsServer.test.ts | 74 ++++++++++++++++++++++++++++++++ apps/server/src/wsServer.ts | 18 +++++++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 1a464c3e437d..0f34c875299d 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -582,6 +582,80 @@ describe("WebSocket Server", () => { }); }); + it("infers shadow sync engine mode in server.getConfig when mode option omitted", async () => { + const stateDir = makeTempDir("t3code-ws-config-shadow-infer-state-"); + const persistenceService = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService }); + const mirror = new LiveStoreStateMirror({ storeId: "ws-config-shadow-infer-test" }); + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + server = createTestServer({ + cwd: "/my/workspace", + stateSyncEngine: shadow, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + syncEngineMode: "shadow", + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + }); + } finally { + shadow.close(); + } + }); + + it("infers read-pilot sync engine mode in server.getConfig when mode option omitted", async () => { + const stateDir = makeTempDir("t3code-ws-config-readpilot-infer-state-"); + const persistenceService = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService }); + const mirror = new LiveStoreStateMirror({ storeId: "ws-config-readpilot-infer-test" }); + const readPilot = new LiveStoreReadPilotStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + server = createTestServer({ + cwd: "/my/workspace", + stateSyncEngine: readPilot, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + syncEngineMode: "livestore-read-pilot", + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + }); + } finally { + readPilot.close(); + } + }); + it("reads keybindings from ~/.t3/keybindings.json", async () => { const fakeHome = makeTempDir("t3code-home-"); const configDir = path.join(fakeHome, ".t3"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 53102682b49e..fc8caf2370ed 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -35,6 +35,8 @@ import { searchWorkspaceEntries } from "./workspaceEntries"; import { PersistenceService } from "./persistenceService"; import type { StateSyncEngine } from "./stateSyncEngine"; import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; +import { LiveStoreReadPilotStateSyncEngine } from "./stateSyncEngineLiveStoreReadPilot"; +import { ShadowStateSyncEngine } from "./stateSyncEngineShadow"; import type { SyncEngineMode } from "./syncEngineMode"; const MIME_TYPES: Record = { @@ -90,7 +92,7 @@ export function createServer(options: ServerOptions) { gitManager: providedGitManager, terminalManager: providedTerminalManager, authToken, - syncEngineMode = "legacy", + syncEngineMode: providedSyncEngineMode, } = options; const persistenceService = providedPersistenceService ?? @@ -100,6 +102,7 @@ export function createServer(options: ServerOptions) { const ownsPersistenceService = providedPersistenceService === undefined; const stateSyncEngine = providedStateSyncEngine ?? new LegacyStateSyncEngine({ persistenceService }); + const syncEngineMode = providedSyncEngineMode ?? inferSyncEngineMode(stateSyncEngine); const ownsStateSyncEngine = providedStateSyncEngine === undefined; const providerManager = new ProviderManager({ persistenceService }); const terminalManager = providedTerminalManager ?? new TerminalManager(); @@ -612,3 +615,16 @@ export function createServer(options: ServerOptions) { return { start, stop, httpServer }; } + +function inferSyncEngineMode(stateSyncEngine: StateSyncEngine): SyncEngineMode { + if (stateSyncEngine instanceof LiveStoreReadPilotStateSyncEngine) { + return "livestore-read-pilot"; + } + if (stateSyncEngine instanceof ShadowStateSyncEngine) { + return "shadow"; + } + if (stateSyncEngine instanceof LegacyStateSyncEngine) { + return "legacy"; + } + return "legacy"; +} From e7d37a3643aa974d812ff843065b2f6d0435e04c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:28:32 +0000 Subject: [PATCH 30/42] Add enforce-liveStore startup guard for sync engine mode Co-authored-by: Julius Marminge --- README.md | 4 ++++ SYNC_ENGINE_MIGRATION.md | 6 ++++++ apps/server/src/index.ts | 8 ++++++- apps/server/src/syncEngineMode.test.ts | 29 +++++++++++++++++++++++++- apps/server/src/syncEngineMode.ts | 14 +++++++++++++ 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 26d5ba6ef93b..61c599b996dd 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ The server supports feature-flagged sync-engine modes while migrating from the l - `T3CODE_SYNC_ENGINE_MODE=shadow` - Keeps legacy state as canonical, but mirrors committed `state.event` traffic into a LiveStore shadow store for parity validation. +- `T3CODE_LIVESTORE_ENFORCE_MODE=1` + - Disallows `legacy` and `shadow` sync modes at startup. + - Intended for post-cutover environments to prevent accidental rollback to pre-LiveStore modes. + Optional diagnostics: - `T3CODE_LIVESTORE_BOOTSTRAP_PARITY_CHECK=1` diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md index f70a9acc5739..5baca1870013 100644 --- a/SYNC_ENGINE_MIGRATION.md +++ b/SYNC_ENGINE_MIGRATION.md @@ -11,6 +11,12 @@ Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: - `legacy`: legacy persistence service is canonical for reads/writes. - `shadow`: legacy remains canonical, and `state.event` writes are mirrored into LiveStore. +Optional safety gate: + +- `T3CODE_LIVESTORE_ENFORCE_MODE=1` + - prevents startup in `legacy` or `shadow` mode. + - use in post-cutover environments to block accidental rollback. + ## Diagnostics flags - `T3CODE_LIVESTORE_BOOTSTRAP_PARITY_CHECK=1` diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 02fe515a4aa8..ca013ce2b33b 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -11,7 +11,7 @@ import { PersistenceService } from "./persistenceService"; import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; import { LiveStoreReadPilotStateSyncEngine } from "./stateSyncEngineLiveStoreReadPilot"; import { ShadowStateSyncEngine } from "./stateSyncEngineShadow"; -import { resolveSyncEngineMode } from "./syncEngineMode"; +import { assertSyncEngineModeAllowed, resolveSyncEngineMode } from "./syncEngineMode"; import { createServer } from "./wsServer"; fixPath(); @@ -116,6 +116,11 @@ async function main() { const legacyStateDir = resolveStateDir(process.env.T3CODE_STATE_DIR); const stateDbPath = resolveStateDbPath(process.env.T3CODE_STATE_DB_PATH); const syncEngineMode = resolveSyncEngineMode(process.env.T3CODE_SYNC_ENGINE_MODE); + const enforceLiveStoreOnly = + parseBooleanEnv(process.env.T3CODE_LIVESTORE_ENFORCE_MODE) ?? false; + assertSyncEngineModeAllowed(syncEngineMode, { + enforceLiveStoreOnly, + }); const enableLiveStoreBootstrapParityCheck = parseBooleanEnv(process.env.T3CODE_LIVESTORE_BOOTSTRAP_PARITY_CHECK) ?? false; const enableLiveStoreCatchUpParityCheck = @@ -191,6 +196,7 @@ async function main() { stateDbPath, legacyStateDir, syncEngineMode, + enforceLiveStoreOnly, liveStoreBootstrapParityCheck: enableLiveStoreBootstrapParityCheck, liveStoreCatchUpParityCheck: enableLiveStoreCatchUpParityCheck, liveStoreListMessagesParityCheck: enableLiveStoreListMessagesParityCheck, diff --git a/apps/server/src/syncEngineMode.test.ts b/apps/server/src/syncEngineMode.test.ts index ff7834ee87ae..a5d61dd7aa83 100644 --- a/apps/server/src/syncEngineMode.test.ts +++ b/apps/server/src/syncEngineMode.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveSyncEngineMode } from "./syncEngineMode"; +import { assertSyncEngineModeAllowed, resolveSyncEngineMode } from "./syncEngineMode"; describe("resolveSyncEngineMode", () => { it("defaults to livestore-read-pilot when env var is missing", () => { @@ -21,3 +21,30 @@ describe("resolveSyncEngineMode", () => { ); }); }); + +describe("assertSyncEngineModeAllowed", () => { + it("allows any mode when enforcement is disabled", () => { + expect(() => assertSyncEngineModeAllowed("legacy")).not.toThrow(); + expect(() => assertSyncEngineModeAllowed("shadow")).not.toThrow(); + expect(() => assertSyncEngineModeAllowed("livestore-read-pilot")).not.toThrow(); + expect(() => assertSyncEngineModeAllowed("livestore")).not.toThrow(); + }); + + it("blocks legacy and shadow when enforceLiveStoreOnly is enabled", () => { + expect(() => + assertSyncEngineModeAllowed("legacy", { enforceLiveStoreOnly: true }), + ).toThrow(/disabled/i); + expect(() => + assertSyncEngineModeAllowed("shadow", { enforceLiveStoreOnly: true }), + ).toThrow(/disabled/i); + }); + + it("allows livestore modes when enforceLiveStoreOnly is enabled", () => { + expect(() => + assertSyncEngineModeAllowed("livestore-read-pilot", { enforceLiveStoreOnly: true }), + ).not.toThrow(); + expect(() => + assertSyncEngineModeAllowed("livestore", { enforceLiveStoreOnly: true }), + ).not.toThrow(); + }); +}); diff --git a/apps/server/src/syncEngineMode.ts b/apps/server/src/syncEngineMode.ts index d684cbe9a6a7..bda8ba7f0427 100644 --- a/apps/server/src/syncEngineMode.ts +++ b/apps/server/src/syncEngineMode.ts @@ -19,3 +19,17 @@ export function resolveSyncEngineMode(raw: string | undefined): SyncEngineMode { `Invalid T3CODE_SYNC_ENGINE_MODE: ${raw}. Expected "legacy", "shadow", "livestore-read-pilot", or "livestore".`, ); } + +export function assertSyncEngineModeAllowed( + syncEngineMode: SyncEngineMode, + options: { enforceLiveStoreOnly?: boolean } = {}, +): void { + if (!options.enforceLiveStoreOnly) { + return; + } + if (syncEngineMode === "legacy" || syncEngineMode === "shadow") { + throw new Error( + `Sync engine mode "${syncEngineMode}" is disabled because T3CODE_LIVESTORE_ENFORCE_MODE is enabled.`, + ); + } +} From 842435e62267704f6cf11aa0fe847d6ac21bd054 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:30:37 +0000 Subject: [PATCH 31/42] Promote strict livestore mode to default sync engine Co-authored-by: Julius Marminge --- README.md | 6 +++--- SYNC_ENGINE_MIGRATION.md | 4 ++-- apps/server/src/syncEngineMode.test.ts | 8 ++++---- apps/server/src/syncEngineMode.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 61c599b996dd..2c2acf8eb65b 100644 --- a/README.md +++ b/README.md @@ -105,11 +105,11 @@ T3 Code has a global runtime mode switch in the chat toolbar: The server supports feature-flagged sync-engine modes while migrating from the legacy in-house state sync pipeline to LiveStore. -- `T3CODE_SYNC_ENGINE_MODE=livestore-read-pilot` (default) +- `T3CODE_SYNC_ENGINE_MODE=livestore` (default) + - Uses the LiveStore-backed read path and disables delegate fallback (strict mirror-read mode). +- `T3CODE_SYNC_ENGINE_MODE=livestore-read-pilot` - Uses the LiveStore mirror for `state.bootstrap`, `state.catchUp`, and `state.listMessages` reads when available. - Automatically falls back to legacy reads if the mirror is unavailable or errors. -- `T3CODE_SYNC_ENGINE_MODE=livestore` - - Uses the same LiveStore-backed read path as read-pilot, but disables delegate fallback (strict mirror-read mode). - `T3CODE_SYNC_ENGINE_MODE=legacy` - Uses the existing `PersistenceService`-backed state sync engine for reads and writes. - `T3CODE_SYNC_ENGINE_MODE=shadow` diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md index 5baca1870013..1d0834cbcbc3 100644 --- a/SYNC_ENGINE_MIGRATION.md +++ b/SYNC_ENGINE_MIGRATION.md @@ -6,8 +6,8 @@ This runbook describes how to operate and verify the LiveStore migration path sa Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: -- `livestore-read-pilot` (default): write path remains legacy-backed, but state reads prefer LiveStore mirror with delegate fallback. -- `livestore`: same read path as read-pilot, but with delegate read fallback disabled (strict mirror-read mode). +- `livestore` (default): strict LiveStore read path (delegate read fallback disabled). +- `livestore-read-pilot`: write path remains legacy-backed, but state reads prefer LiveStore mirror with delegate fallback. - `legacy`: legacy persistence service is canonical for reads/writes. - `shadow`: legacy remains canonical, and `state.event` writes are mirrored into LiveStore. diff --git a/apps/server/src/syncEngineMode.test.ts b/apps/server/src/syncEngineMode.test.ts index a5d61dd7aa83..961aea7bd42e 100644 --- a/apps/server/src/syncEngineMode.test.ts +++ b/apps/server/src/syncEngineMode.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest"; import { assertSyncEngineModeAllowed, resolveSyncEngineMode } from "./syncEngineMode"; describe("resolveSyncEngineMode", () => { - it("defaults to livestore-read-pilot when env var is missing", () => { - expect(resolveSyncEngineMode(undefined)).toBe("livestore-read-pilot"); - expect(resolveSyncEngineMode("")).toBe("livestore-read-pilot"); - expect(resolveSyncEngineMode(" ")).toBe("livestore-read-pilot"); + it("defaults to livestore when env var is missing", () => { + expect(resolveSyncEngineMode(undefined)).toBe("livestore"); + expect(resolveSyncEngineMode("")).toBe("livestore"); + expect(resolveSyncEngineMode(" ")).toBe("livestore"); }); it("accepts supported mode values case-insensitively", () => { diff --git a/apps/server/src/syncEngineMode.ts b/apps/server/src/syncEngineMode.ts index bda8ba7f0427..b318dbe79e53 100644 --- a/apps/server/src/syncEngineMode.ts +++ b/apps/server/src/syncEngineMode.ts @@ -9,7 +9,7 @@ const VALID_SYNC_ENGINE_MODES: ReadonlySet = new Set([ export function resolveSyncEngineMode(raw: string | undefined): SyncEngineMode { if (!raw || raw.trim().length === 0) { - return "livestore-read-pilot"; + return "livestore"; } const normalized = raw.trim().toLowerCase(); if (VALID_SYNC_ENGINE_MODES.has(normalized as SyncEngineMode)) { From 93686a84b3a3657e3ae4b8867334ffbecab1a299 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:33:31 +0000 Subject: [PATCH 32/42] Keep read-pilot as default pending durable mirror bootstrap Co-authored-by: Julius Marminge --- README.md | 6 +++--- SYNC_ENGINE_MIGRATION.md | 4 ++-- apps/server/src/syncEngineMode.test.ts | 8 ++++---- apps/server/src/syncEngineMode.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2c2acf8eb65b..a7805ef45ba4 100644 --- a/README.md +++ b/README.md @@ -105,11 +105,11 @@ T3 Code has a global runtime mode switch in the chat toolbar: The server supports feature-flagged sync-engine modes while migrating from the legacy in-house state sync pipeline to LiveStore. -- `T3CODE_SYNC_ENGINE_MODE=livestore` (default) - - Uses the LiveStore-backed read path and disables delegate fallback (strict mirror-read mode). -- `T3CODE_SYNC_ENGINE_MODE=livestore-read-pilot` +- `T3CODE_SYNC_ENGINE_MODE=livestore-read-pilot` (default) - Uses the LiveStore mirror for `state.bootstrap`, `state.catchUp`, and `state.listMessages` reads when available. - Automatically falls back to legacy reads if the mirror is unavailable or errors. +- `T3CODE_SYNC_ENGINE_MODE=livestore` + - Uses the LiveStore-backed read path and disables delegate fallback (strict mirror-read mode). - `T3CODE_SYNC_ENGINE_MODE=legacy` - Uses the existing `PersistenceService`-backed state sync engine for reads and writes. - `T3CODE_SYNC_ENGINE_MODE=shadow` diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md index 1d0834cbcbc3..2eb1f3186f26 100644 --- a/SYNC_ENGINE_MIGRATION.md +++ b/SYNC_ENGINE_MIGRATION.md @@ -6,8 +6,8 @@ This runbook describes how to operate and verify the LiveStore migration path sa Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: -- `livestore` (default): strict LiveStore read path (delegate read fallback disabled). -- `livestore-read-pilot`: write path remains legacy-backed, but state reads prefer LiveStore mirror with delegate fallback. +- `livestore-read-pilot` (default): write path remains legacy-backed, but state reads prefer LiveStore mirror with delegate fallback. +- `livestore`: strict LiveStore read path (delegate read fallback disabled). - `legacy`: legacy persistence service is canonical for reads/writes. - `shadow`: legacy remains canonical, and `state.event` writes are mirrored into LiveStore. diff --git a/apps/server/src/syncEngineMode.test.ts b/apps/server/src/syncEngineMode.test.ts index 961aea7bd42e..a5d61dd7aa83 100644 --- a/apps/server/src/syncEngineMode.test.ts +++ b/apps/server/src/syncEngineMode.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest"; import { assertSyncEngineModeAllowed, resolveSyncEngineMode } from "./syncEngineMode"; describe("resolveSyncEngineMode", () => { - it("defaults to livestore when env var is missing", () => { - expect(resolveSyncEngineMode(undefined)).toBe("livestore"); - expect(resolveSyncEngineMode("")).toBe("livestore"); - expect(resolveSyncEngineMode(" ")).toBe("livestore"); + it("defaults to livestore-read-pilot when env var is missing", () => { + expect(resolveSyncEngineMode(undefined)).toBe("livestore-read-pilot"); + expect(resolveSyncEngineMode("")).toBe("livestore-read-pilot"); + expect(resolveSyncEngineMode(" ")).toBe("livestore-read-pilot"); }); it("accepts supported mode values case-insensitively", () => { diff --git a/apps/server/src/syncEngineMode.ts b/apps/server/src/syncEngineMode.ts index b318dbe79e53..bda8ba7f0427 100644 --- a/apps/server/src/syncEngineMode.ts +++ b/apps/server/src/syncEngineMode.ts @@ -9,7 +9,7 @@ const VALID_SYNC_ENGINE_MODES: ReadonlySet = new Set([ export function resolveSyncEngineMode(raw: string | undefined): SyncEngineMode { if (!raw || raw.trim().length === 0) { - return "livestore"; + return "livestore-read-pilot"; } const normalized = raw.trim().toLowerCase(); if (VALID_SYNC_ENGINE_MODES.has(normalized as SyncEngineMode)) { From 2dd87351ac1a7eb5446ac484bbf49b2168e46780 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:37:22 +0000 Subject: [PATCH 33/42] Bootstrap LiveStore mirror from persisted catch-up history Co-authored-by: Julius Marminge --- README.md | 1 + SYNC_ENGINE_MIGRATION.md | 4 + apps/server/src/index.ts | 15 +++ .../src/livestore/mirrorBootstrap.test.ts | 115 ++++++++++++++++++ apps/server/src/livestore/mirrorBootstrap.ts | 57 +++++++++ 5 files changed, 192 insertions(+) create mode 100644 apps/server/src/livestore/mirrorBootstrap.test.ts create mode 100644 apps/server/src/livestore/mirrorBootstrap.ts diff --git a/README.md b/README.md index a7805ef45ba4..fe54becd2e5f 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ The server supports feature-flagged sync-engine modes while migrating from the l - `T3CODE_SYNC_ENGINE_MODE=livestore-read-pilot` (default) - Uses the LiveStore mirror for `state.bootstrap`, `state.catchUp`, and `state.listMessages` reads when available. - Automatically falls back to legacy reads if the mirror is unavailable or errors. + - Mirror state is pre-bootstrapped from persisted catch-up history during server startup. - `T3CODE_SYNC_ENGINE_MODE=livestore` - Uses the LiveStore-backed read path and disables delegate fallback (strict mirror-read mode). - `T3CODE_SYNC_ENGINE_MODE=legacy` diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md index 2eb1f3186f26..7b73ef6f8a43 100644 --- a/SYNC_ENGINE_MIGRATION.md +++ b/SYNC_ENGINE_MIGRATION.md @@ -11,6 +11,10 @@ Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: - `legacy`: legacy persistence service is canonical for reads/writes. - `shadow`: legacy remains canonical, and `state.event` writes are mirrored into LiveStore. +Startup behavior: + +- when running `shadow`, `livestore-read-pilot`, or `livestore`, the mirror is bootstrapped from persisted catch-up history before serving requests. + Optional safety gate: - `T3CODE_LIVESTORE_ENFORCE_MODE=1` diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index ca013ce2b33b..b69d16ecd7bd 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { fixPath } from "./fixPath"; import { createLogger } from "./logger"; +import { bootstrapMirrorFromCatchUp } from "./livestore/mirrorBootstrap"; import { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; import { PersistenceService } from "./persistenceService"; import { LegacyStateSyncEngine } from "./stateSyncEngineLegacy"; @@ -144,6 +145,20 @@ async function main() { storeId: `t3-shadow-${mode}`, }); const isReadPilotMode = syncEngineMode === "livestore-read-pilot" || syncEngineMode === "livestore"; + if (syncEngineMode === "shadow" || isReadPilotMode) { + const bootstrapResult = bootstrapMirrorFromCatchUp({ + source: legacyStateSyncEngine, + mirror: liveStoreMirror, + logger, + failOnError: syncEngineMode === "livestore", + }); + logger.info("bootstrapped livestore mirror from persisted catch-up history", { + mirroredCount: bootstrapResult.mirroredCount, + lastStateSeq: bootstrapResult.lastStateSeq, + complete: bootstrapResult.complete, + syncEngineMode, + }); + } const stateSyncEngine = syncEngineMode === "shadow" ? new ShadowStateSyncEngine({ diff --git a/apps/server/src/livestore/mirrorBootstrap.test.ts b/apps/server/src/livestore/mirrorBootstrap.test.ts new file mode 100644 index 000000000000..2c81988e32dd --- /dev/null +++ b/apps/server/src/livestore/mirrorBootstrap.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from "vitest"; +import { createLogger } from "../logger"; +import { bootstrapMirrorFromCatchUp } from "./mirrorBootstrap"; + +describe("bootstrapMirrorFromCatchUp", () => { + it("mirrors catch-up history into the mirror sink", () => { + const mirrorStateEvent = vi.fn(); + const source = { + catchUp: vi.fn().mockReturnValue({ + events: [ + { + seq: 1, + entityId: "project-1", + eventType: "project.upsert", + payload: { id: "project-1", name: "Demo", path: "/demo" }, + createdAt: new Date().toISOString(), + }, + { + seq: 2, + entityId: "thread-1", + eventType: "thread.upsert", + payload: { id: "thread-1", title: "Thread" }, + createdAt: new Date().toISOString(), + }, + ], + lastStateSeq: 2, + }), + }; + + const result = bootstrapMirrorFromCatchUp({ + source, + mirror: { mirrorStateEvent }, + logger: createLogger("mirror-bootstrap-test"), + }); + + expect(result).toEqual({ + mirroredCount: 2, + lastStateSeq: 2, + complete: true, + }); + expect(mirrorStateEvent).toHaveBeenCalledTimes(2); + }); + + it("returns partial progress when non-fatal mirroring errors occur", () => { + const mirrorStateEvent = vi + .fn() + .mockImplementationOnce(() => {}) + .mockImplementationOnce(() => { + throw new Error("mirror write failed"); + }); + const source = { + catchUp: vi.fn().mockReturnValue({ + events: [ + { + seq: 1, + entityId: "project-1", + eventType: "project.upsert", + payload: { id: "project-1", name: "Demo", path: "/demo" }, + createdAt: new Date().toISOString(), + }, + { + seq: 2, + entityId: "thread-1", + eventType: "thread.upsert", + payload: { id: "thread-1", title: "Thread" }, + createdAt: new Date().toISOString(), + }, + ], + lastStateSeq: 2, + }), + }; + + const result = bootstrapMirrorFromCatchUp({ + source, + mirror: { mirrorStateEvent }, + logger: createLogger("mirror-bootstrap-test"), + }); + + expect(result).toEqual({ + mirroredCount: 1, + lastStateSeq: 2, + complete: false, + }); + expect(mirrorStateEvent).toHaveBeenCalledTimes(2); + }); + + it("throws when failOnError is enabled", () => { + const mirrorStateEvent = vi.fn().mockImplementation(() => { + throw new Error("mirror write failed"); + }); + const source = { + catchUp: vi.fn().mockReturnValue({ + events: [ + { + seq: 1, + entityId: "project-1", + eventType: "project.upsert", + payload: { id: "project-1", name: "Demo", path: "/demo" }, + createdAt: new Date().toISOString(), + }, + ], + lastStateSeq: 1, + }), + }; + + expect(() => + bootstrapMirrorFromCatchUp({ + source, + mirror: { mirrorStateEvent }, + logger: createLogger("mirror-bootstrap-test"), + failOnError: true, + }), + ).toThrow(/failed to bootstrap livestore mirror/i); + }); +}); diff --git a/apps/server/src/livestore/mirrorBootstrap.ts b/apps/server/src/livestore/mirrorBootstrap.ts new file mode 100644 index 000000000000..45f5cd2618c6 --- /dev/null +++ b/apps/server/src/livestore/mirrorBootstrap.ts @@ -0,0 +1,57 @@ +import type { StateCatchUpResult, StateEvent } from "@t3tools/contracts"; + +interface CatchUpSource { + catchUp(input: { afterSeq: number }): StateCatchUpResult; +} + +interface MirrorSink { + mirrorStateEvent(event: StateEvent): void; +} + +interface LoggerLike { + warn(message: string, context?: Record): void; +} + +export interface MirrorBootstrapResult { + mirroredCount: number; + lastStateSeq: number; + complete: boolean; +} + +export function bootstrapMirrorFromCatchUp(options: { + source: CatchUpSource; + mirror: MirrorSink; + logger: LoggerLike; + failOnError?: boolean; +}): MirrorBootstrapResult { + const { source, mirror, logger, failOnError = false } = options; + const catchUpResult = source.catchUp({ afterSeq: 0 }); + let mirroredCount = 0; + for (const event of catchUpResult.events) { + try { + mirror.mirrorStateEvent(event); + mirroredCount += 1; + } catch (error) { + if (failOnError) { + throw new Error("failed to bootstrap livestore mirror from catch-up history", { + cause: error, + }); + } + logger.warn("failed to bootstrap livestore mirror event; continuing with partial mirror state", { + error, + eventSeq: event.seq, + mirroredCount, + }); + return { + mirroredCount, + lastStateSeq: catchUpResult.lastStateSeq, + complete: false, + }; + } + } + return { + mirroredCount, + lastStateSeq: catchUpResult.lastStateSeq, + complete: true, + }; +} From 07f4dbf48044e9db00cbd559681918f0f5bf6ef1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:39:17 +0000 Subject: [PATCH 34/42] Await mirror replay during startup hydration Co-authored-by: Julius Marminge --- apps/server/src/index.ts | 2 +- apps/server/src/livestore/mirrorBootstrap.test.ts | 14 +++++++------- apps/server/src/livestore/mirrorBootstrap.ts | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index b69d16ecd7bd..de25ade5f56b 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -146,7 +146,7 @@ async function main() { }); const isReadPilotMode = syncEngineMode === "livestore-read-pilot" || syncEngineMode === "livestore"; if (syncEngineMode === "shadow" || isReadPilotMode) { - const bootstrapResult = bootstrapMirrorFromCatchUp({ + const bootstrapResult = await bootstrapMirrorFromCatchUp({ source: legacyStateSyncEngine, mirror: liveStoreMirror, logger, diff --git a/apps/server/src/livestore/mirrorBootstrap.test.ts b/apps/server/src/livestore/mirrorBootstrap.test.ts index 2c81988e32dd..1d2dc1dc59c9 100644 --- a/apps/server/src/livestore/mirrorBootstrap.test.ts +++ b/apps/server/src/livestore/mirrorBootstrap.test.ts @@ -3,7 +3,7 @@ import { createLogger } from "../logger"; import { bootstrapMirrorFromCatchUp } from "./mirrorBootstrap"; describe("bootstrapMirrorFromCatchUp", () => { - it("mirrors catch-up history into the mirror sink", () => { + it("mirrors catch-up history into the mirror sink", async () => { const mirrorStateEvent = vi.fn(); const source = { catchUp: vi.fn().mockReturnValue({ @@ -27,7 +27,7 @@ describe("bootstrapMirrorFromCatchUp", () => { }), }; - const result = bootstrapMirrorFromCatchUp({ + const result = await bootstrapMirrorFromCatchUp({ source, mirror: { mirrorStateEvent }, logger: createLogger("mirror-bootstrap-test"), @@ -41,7 +41,7 @@ describe("bootstrapMirrorFromCatchUp", () => { expect(mirrorStateEvent).toHaveBeenCalledTimes(2); }); - it("returns partial progress when non-fatal mirroring errors occur", () => { + it("returns partial progress when non-fatal mirroring errors occur", async () => { const mirrorStateEvent = vi .fn() .mockImplementationOnce(() => {}) @@ -70,7 +70,7 @@ describe("bootstrapMirrorFromCatchUp", () => { }), }; - const result = bootstrapMirrorFromCatchUp({ + const result = await bootstrapMirrorFromCatchUp({ source, mirror: { mirrorStateEvent }, logger: createLogger("mirror-bootstrap-test"), @@ -84,7 +84,7 @@ describe("bootstrapMirrorFromCatchUp", () => { expect(mirrorStateEvent).toHaveBeenCalledTimes(2); }); - it("throws when failOnError is enabled", () => { + it("throws when failOnError is enabled", async () => { const mirrorStateEvent = vi.fn().mockImplementation(() => { throw new Error("mirror write failed"); }); @@ -103,13 +103,13 @@ describe("bootstrapMirrorFromCatchUp", () => { }), }; - expect(() => + await expect( bootstrapMirrorFromCatchUp({ source, mirror: { mirrorStateEvent }, logger: createLogger("mirror-bootstrap-test"), failOnError: true, }), - ).toThrow(/failed to bootstrap livestore mirror/i); + ).rejects.toThrow(/failed to bootstrap livestore mirror/i); }); }); diff --git a/apps/server/src/livestore/mirrorBootstrap.ts b/apps/server/src/livestore/mirrorBootstrap.ts index 45f5cd2618c6..05ac42e27da3 100644 --- a/apps/server/src/livestore/mirrorBootstrap.ts +++ b/apps/server/src/livestore/mirrorBootstrap.ts @@ -5,7 +5,7 @@ interface CatchUpSource { } interface MirrorSink { - mirrorStateEvent(event: StateEvent): void; + mirrorStateEvent(event: StateEvent): void | Promise; } interface LoggerLike { @@ -18,18 +18,18 @@ export interface MirrorBootstrapResult { complete: boolean; } -export function bootstrapMirrorFromCatchUp(options: { +export async function bootstrapMirrorFromCatchUp(options: { source: CatchUpSource; mirror: MirrorSink; logger: LoggerLike; failOnError?: boolean; -}): MirrorBootstrapResult { +}): Promise { const { source, mirror, logger, failOnError = false } = options; const catchUpResult = source.catchUp({ afterSeq: 0 }); let mirroredCount = 0; for (const event of catchUpResult.events) { try { - mirror.mirrorStateEvent(event); + await mirror.mirrorStateEvent(event); mirroredCount += 1; } catch (error) { if (failOnError) { From 9a6575655ce66cae64e4d105aece2fece9836a04 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:41:40 +0000 Subject: [PATCH 35/42] Propagate mirror replay success status during bootstrap Co-authored-by: Julius Marminge --- apps/server/src/livestore/liveStoreEngine.ts | 10 ++++--- .../src/livestore/mirrorBootstrap.test.ts | 29 +++++++++++++++++++ apps/server/src/livestore/mirrorBootstrap.ts | 7 +++-- apps/server/src/stateSyncEngineShadow.ts | 2 +- 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/server/src/livestore/liveStoreEngine.ts b/apps/server/src/livestore/liveStoreEngine.ts index f3b24014bd4d..9d22e38daa5e 100644 --- a/apps/server/src/livestore/liveStoreEngine.ts +++ b/apps/server/src/livestore/liveStoreEngine.ts @@ -66,17 +66,17 @@ export class LiveStoreStateMirror implements StateEventMirror { this.storeId = options.storeId ?? "t3-shadow-sync"; } - async mirrorStateEvent(event: StateEvent): Promise { + async mirrorStateEvent(event: StateEvent): Promise { if (!this.enabled || this.disposed) { - return; + return false; } if (event.seq <= this.lastMirroredSeq) { - return; + return true; } const store = await this.getStore(); if (!store) { - return; + return false; } try { @@ -91,12 +91,14 @@ export class LiveStoreStateMirror implements StateEventMirror { ); this.applyProjection(event); this.lastMirroredSeq = event.seq; + return true; } catch (error) { this.logger.warn("failed to commit mirrored state event", { error, seq: event.seq, eventType: event.eventType, }); + return false; } } diff --git a/apps/server/src/livestore/mirrorBootstrap.test.ts b/apps/server/src/livestore/mirrorBootstrap.test.ts index 1d2dc1dc59c9..384e9b88730c 100644 --- a/apps/server/src/livestore/mirrorBootstrap.test.ts +++ b/apps/server/src/livestore/mirrorBootstrap.test.ts @@ -112,4 +112,33 @@ describe("bootstrapMirrorFromCatchUp", () => { }), ).rejects.toThrow(/failed to bootstrap livestore mirror/i); }); + + it("treats mirrorStateEvent false return as a bootstrap failure", async () => { + const source = { + catchUp: vi.fn().mockReturnValue({ + events: [ + { + seq: 1, + entityId: "project-1", + eventType: "project.upsert", + payload: { id: "project-1", name: "Demo", path: "/demo" }, + createdAt: new Date().toISOString(), + }, + ], + lastStateSeq: 1, + }), + }; + + const result = await bootstrapMirrorFromCatchUp({ + source, + mirror: { mirrorStateEvent: vi.fn().mockResolvedValue(false) }, + logger: createLogger("mirror-bootstrap-test"), + }); + + expect(result).toEqual({ + mirroredCount: 0, + lastStateSeq: 1, + complete: false, + }); + }); }); diff --git a/apps/server/src/livestore/mirrorBootstrap.ts b/apps/server/src/livestore/mirrorBootstrap.ts index 05ac42e27da3..8ac6e418c33d 100644 --- a/apps/server/src/livestore/mirrorBootstrap.ts +++ b/apps/server/src/livestore/mirrorBootstrap.ts @@ -5,7 +5,7 @@ interface CatchUpSource { } interface MirrorSink { - mirrorStateEvent(event: StateEvent): void | Promise; + mirrorStateEvent(event: StateEvent): void | boolean | Promise; } interface LoggerLike { @@ -29,7 +29,10 @@ export async function bootstrapMirrorFromCatchUp(options: { let mirroredCount = 0; for (const event of catchUpResult.events) { try { - await mirror.mirrorStateEvent(event); + const mirrorResult = await mirror.mirrorStateEvent(event); + if (mirrorResult === false) { + throw new Error("mirrorStateEvent returned false"); + } mirroredCount += 1; } catch (error) { if (failOnError) { diff --git a/apps/server/src/stateSyncEngineShadow.ts b/apps/server/src/stateSyncEngineShadow.ts index fe4a2ff9f273..3fd0f6a056c3 100644 --- a/apps/server/src/stateSyncEngineShadow.ts +++ b/apps/server/src/stateSyncEngineShadow.ts @@ -32,7 +32,7 @@ interface StateSyncEngineShadowEvents { } export interface StateEventMirror { - mirrorStateEvent(event: StateEvent): Promise; + mirrorStateEvent(event: StateEvent): Promise; debugReadSnapshot?(): StateBootstrapResult; debugCatchUp?(afterSeq: number): StateCatchUpResult; debugListMessages?(raw: StateListMessagesInput): StateListMessagesResult; From 7ee0cce5729a6a62413c79365c173031fc472b38 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:43:05 +0000 Subject: [PATCH 36/42] Restore strict livestore as default after startup hydration hardening Co-authored-by: Julius Marminge --- README.md | 8 ++++---- SYNC_ENGINE_MIGRATION.md | 4 ++-- apps/server/src/syncEngineMode.test.ts | 8 ++++---- apps/server/src/syncEngineMode.ts | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index fe54becd2e5f..4fb89eeafb2c 100644 --- a/README.md +++ b/README.md @@ -105,12 +105,12 @@ T3 Code has a global runtime mode switch in the chat toolbar: The server supports feature-flagged sync-engine modes while migrating from the legacy in-house state sync pipeline to LiveStore. -- `T3CODE_SYNC_ENGINE_MODE=livestore-read-pilot` (default) +- `T3CODE_SYNC_ENGINE_MODE=livestore` (default) + - Uses the LiveStore-backed read path and disables delegate fallback (strict mirror-read mode). + - Mirror state is pre-bootstrapped from persisted catch-up history during server startup. +- `T3CODE_SYNC_ENGINE_MODE=livestore-read-pilot` - Uses the LiveStore mirror for `state.bootstrap`, `state.catchUp`, and `state.listMessages` reads when available. - Automatically falls back to legacy reads if the mirror is unavailable or errors. - - Mirror state is pre-bootstrapped from persisted catch-up history during server startup. -- `T3CODE_SYNC_ENGINE_MODE=livestore` - - Uses the LiveStore-backed read path and disables delegate fallback (strict mirror-read mode). - `T3CODE_SYNC_ENGINE_MODE=legacy` - Uses the existing `PersistenceService`-backed state sync engine for reads and writes. - `T3CODE_SYNC_ENGINE_MODE=shadow` diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md index 7b73ef6f8a43..986c2e015271 100644 --- a/SYNC_ENGINE_MIGRATION.md +++ b/SYNC_ENGINE_MIGRATION.md @@ -6,8 +6,8 @@ This runbook describes how to operate and verify the LiveStore migration path sa Server mode is controlled by `T3CODE_SYNC_ENGINE_MODE`: -- `livestore-read-pilot` (default): write path remains legacy-backed, but state reads prefer LiveStore mirror with delegate fallback. -- `livestore`: strict LiveStore read path (delegate read fallback disabled). +- `livestore` (default): strict LiveStore read path (delegate read fallback disabled). +- `livestore-read-pilot`: write path remains legacy-backed, but state reads prefer LiveStore mirror with delegate fallback. - `legacy`: legacy persistence service is canonical for reads/writes. - `shadow`: legacy remains canonical, and `state.event` writes are mirrored into LiveStore. diff --git a/apps/server/src/syncEngineMode.test.ts b/apps/server/src/syncEngineMode.test.ts index a5d61dd7aa83..961aea7bd42e 100644 --- a/apps/server/src/syncEngineMode.test.ts +++ b/apps/server/src/syncEngineMode.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest"; import { assertSyncEngineModeAllowed, resolveSyncEngineMode } from "./syncEngineMode"; describe("resolveSyncEngineMode", () => { - it("defaults to livestore-read-pilot when env var is missing", () => { - expect(resolveSyncEngineMode(undefined)).toBe("livestore-read-pilot"); - expect(resolveSyncEngineMode("")).toBe("livestore-read-pilot"); - expect(resolveSyncEngineMode(" ")).toBe("livestore-read-pilot"); + it("defaults to livestore when env var is missing", () => { + expect(resolveSyncEngineMode(undefined)).toBe("livestore"); + expect(resolveSyncEngineMode("")).toBe("livestore"); + expect(resolveSyncEngineMode(" ")).toBe("livestore"); }); it("accepts supported mode values case-insensitively", () => { diff --git a/apps/server/src/syncEngineMode.ts b/apps/server/src/syncEngineMode.ts index bda8ba7f0427..b318dbe79e53 100644 --- a/apps/server/src/syncEngineMode.ts +++ b/apps/server/src/syncEngineMode.ts @@ -9,7 +9,7 @@ const VALID_SYNC_ENGINE_MODES: ReadonlySet = new Set([ export function resolveSyncEngineMode(raw: string | undefined): SyncEngineMode { if (!raw || raw.trim().length === 0) { - return "livestore-read-pilot"; + return "livestore"; } const normalized = raw.trim().toLowerCase(); if (VALID_SYNC_ENGINE_MODES.has(normalized as SyncEngineMode)) { From 0611b8220f21c24d6c6e1e4fd32901846ffe0650 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:44:11 +0000 Subject: [PATCH 37/42] Default server config contract to strict livestore mode Co-authored-by: Julius Marminge --- packages/contracts/src/server.test.ts | 4 ++-- packages/contracts/src/server.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index f193469f95ad..9476cd22fb73 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { serverConfigSchema } from "./server"; describe("serverConfigSchema", () => { - it("defaults syncEngineMode to legacy when omitted", () => { + it("defaults syncEngineMode to livestore when omitted", () => { expect( serverConfigSchema.parse({ cwd: "/workspace", @@ -10,7 +10,7 @@ describe("serverConfigSchema", () => { }), ).toEqual({ cwd: "/workspace", - syncEngineMode: "legacy", + syncEngineMode: "livestore", keybindings: [], }); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 0cef20be6a21..b65376960010 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -5,7 +5,7 @@ export const serverConfigSchema = z.object({ cwd: z.string().min(1), syncEngineMode: z .enum(["legacy", "shadow", "livestore-read-pilot", "livestore"]) - .default("legacy"), + .default("livestore"), keybindings: resolvedKeybindingsConfigSchema.default([]), }); From c777aae03dd44d2cdc01080ff87a62492440f247 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:47:11 +0000 Subject: [PATCH 38/42] Cover mirror bootstrap replay against real legacy state Co-authored-by: Julius Marminge --- .../src/livestore/mirrorBootstrap.test.ts | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/apps/server/src/livestore/mirrorBootstrap.test.ts b/apps/server/src/livestore/mirrorBootstrap.test.ts index 384e9b88730c..1a8769802922 100644 --- a/apps/server/src/livestore/mirrorBootstrap.test.ts +++ b/apps/server/src/livestore/mirrorBootstrap.test.ts @@ -1,6 +1,28 @@ -import { describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; import { createLogger } from "../logger"; +import { PersistenceService } from "../persistenceService"; +import { LegacyStateSyncEngine } from "../stateSyncEngineLegacy"; import { bootstrapMirrorFromCatchUp } from "./mirrorBootstrap"; +import { LiveStoreStateMirror } from "./liveStoreEngine"; +import { diffStateSnapshots } from "./parity"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); describe("bootstrapMirrorFromCatchUp", () => { it("mirrors catch-up history into the mirror sink", async () => { @@ -141,4 +163,46 @@ describe("bootstrapMirrorFromCatchUp", () => { complete: false, }); }); + + it("replays persisted legacy catch-up history into a real LiveStore mirror", async () => { + const stateDir = makeTempDir("t3code-bootstrap-replay-state-"); + const projectDir = makeTempDir("t3code-bootstrap-replay-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const mirror = new LiveStoreStateMirror({ storeId: "bootstrap-replay-parity-test" }); + + try { + const project = legacy.addProject({ cwd: projectDir }).project; + const thread = legacy.createThread({ + projectId: project.id, + title: "Bootstrap replay parity thread", + }).thread; + service.bindSessionToThread("bootstrap-replay-session", thread.id, "runtime-thread-bootstrap"); + service.persistUserMessageForTurn({ + sessionId: "bootstrap-replay-session", + clientMessageId: "bootstrap-message-1", + clientMessageText: "bootstrap replay parity", + input: "bootstrap replay parity", + attachments: [], + }); + + const expectedSnapshot = legacy.loadSnapshot(); + const bootstrapResult = await bootstrapMirrorFromCatchUp({ + source: legacy, + mirror, + logger: createLogger("mirror-bootstrap-test"), + failOnError: true, + }); + + expect(bootstrapResult.complete).toBe(true); + expect(bootstrapResult.lastStateSeq).toBe(expectedSnapshot.lastStateSeq); + expect(bootstrapResult.mirroredCount).toBeGreaterThan(0); + expect(diffStateSnapshots(expectedSnapshot, mirror.debugReadSnapshot())).toEqual([]); + } finally { + await mirror.dispose(); + service.close(); + } + }); }); From 672551f505a488f90b8bc403157f71fc3681c62e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:49:46 +0000 Subject: [PATCH 39/42] Centralize catch-up and list-message parity diff helpers Co-authored-by: Julius Marminge --- apps/server/src/livestore/parity.test.ts | 115 +++++++++++++++++- apps/server/src/livestore/parity.ts | 86 ++++++++++++- .../src/stateSyncEngineLiveStoreReadPilot.ts | 82 +------------ apps/server/src/stateSyncEngineShadow.ts | 82 +------------ 4 files changed, 201 insertions(+), 164 deletions(-) diff --git a/apps/server/src/livestore/parity.test.ts b/apps/server/src/livestore/parity.test.ts index 25238408b9be..fea291be1c94 100644 --- a/apps/server/src/livestore/parity.test.ts +++ b/apps/server/src/livestore/parity.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import type { StateBootstrapResult } from "@t3tools/contracts"; -import { diffStateSnapshots, isStateSnapshotInParity } from "./parity"; +import { + diffCatchUpResults, + diffListMessagesResults, + diffStateSnapshots, + isStateSnapshotInParity, +} from "./parity"; function makeSnapshot(overrides: Partial = {}): StateBootstrapResult { return { @@ -147,3 +152,111 @@ describe("diffStateSnapshots", () => { expect(isStateSnapshotInParity(expected, actual)).toBe(false); }); }); + +describe("diffCatchUpResults", () => { + it("returns empty diff when catch-up payloads are equal", () => { + const catchUp = { + events: [ + { + seq: 1, + eventType: "project.upsert" as const, + entityId: "project-1", + payload: { project: { id: "project-1", name: "Demo" } }, + createdAt: "2026-02-20T00:00:00.000Z", + }, + ], + lastStateSeq: 1, + }; + + expect(diffCatchUpResults(catchUp, catchUp)).toEqual([]); + }); + + it("returns targeted diffs for catch-up payload drift", () => { + const expected = { + events: [ + { + seq: 1, + eventType: "project.upsert" as const, + entityId: "project-1", + payload: { project: { id: "project-1", name: "Demo" } }, + createdAt: "2026-02-20T00:00:00.000Z", + }, + ], + lastStateSeq: 1, + }; + const actual = { + events: [ + { + seq: 2, + eventType: "project.delete" as const, + entityId: "project-2", + payload: { projectId: "project-2" }, + createdAt: "2026-02-20T00:00:00.000Z", + }, + ], + lastStateSeq: 2, + }; + + expect(diffCatchUpResults(expected, actual)).toEqual( + expect.arrayContaining([ + "lastStateSeq mismatch: expected=1 actual=2", + "events[0].seq mismatch: expected=1 actual=2", + "events[0].eventType mismatch: expected=project.upsert actual=project.delete", + "events[0].entityId mismatch: expected=project-1 actual=project-2", + "events[0].payload mismatch", + ]), + ); + }); +}); + +describe("diffListMessagesResults", () => { + it("returns empty diff for equivalent list-messages responses", () => { + const result = { + messages: [ + { + id: "message-1", + threadId: "thread-1", + role: "user" as const, + text: "hello", + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + streaming: false, + }, + ], + total: 1, + nextOffset: null, + }; + expect(diffListMessagesResults(result, result)).toEqual([]); + }); + + it("returns targeted diffs for list-messages drift", () => { + const expected = { + messages: [], + total: 1, + nextOffset: 1, + }; + const actual = { + messages: [ + { + id: "message-2", + threadId: "thread-1", + role: "assistant" as const, + text: "hi", + createdAt: "2026-02-20T00:00:00.000Z", + updatedAt: "2026-02-20T00:00:00.000Z", + streaming: false, + }, + ], + total: 2, + nextOffset: null, + }; + + expect(diffListMessagesResults(expected, actual)).toEqual( + expect.arrayContaining([ + "total mismatch: expected=1 actual=2", + "nextOffset mismatch: expected=1 actual=null", + "messages.length mismatch: expected=0 actual=1", + ]), + ); + }); +}); diff --git a/apps/server/src/livestore/parity.ts b/apps/server/src/livestore/parity.ts index 4a1e921092a2..64af1375a65c 100644 --- a/apps/server/src/livestore/parity.ts +++ b/apps/server/src/livestore/parity.ts @@ -1,4 +1,8 @@ -import type { StateBootstrapResult } from "@t3tools/contracts"; +import type { + StateBootstrapResult, + StateCatchUpResult, + StateListMessagesResult, +} from "@t3tools/contracts"; export interface SnapshotParityDiff { path: string; @@ -94,3 +98,83 @@ export function isStateSnapshotInParity( ): boolean { return diffStateSnapshots(expectedSnapshot, actualSnapshot).length === 0; } + +export function diffCatchUpResults(expected: StateCatchUpResult, actual: StateCatchUpResult): string[] { + const diffs: string[] = []; + + if (expected.lastStateSeq !== actual.lastStateSeq) { + diffs.push( + `lastStateSeq mismatch: expected=${expected.lastStateSeq} actual=${actual.lastStateSeq}`, + ); + } + + if (expected.events.length !== actual.events.length) { + diffs.push(`events.length mismatch: expected=${expected.events.length} actual=${actual.events.length}`); + } + + const minLength = Math.min(expected.events.length, actual.events.length); + for (let index = 0; index < minLength; index += 1) { + const expectedEvent = expected.events[index]; + const actualEvent = actual.events[index]; + if (!expectedEvent || !actualEvent) { + continue; + } + if (expectedEvent.seq !== actualEvent.seq) { + diffs.push(`events[${index}].seq mismatch: expected=${expectedEvent.seq} actual=${actualEvent.seq}`); + } + if (expectedEvent.eventType !== actualEvent.eventType) { + diffs.push( + `events[${index}].eventType mismatch: expected=${expectedEvent.eventType} actual=${actualEvent.eventType}`, + ); + } + if (expectedEvent.entityId !== actualEvent.entityId) { + diffs.push( + `events[${index}].entityId mismatch: expected=${expectedEvent.entityId} actual=${actualEvent.entityId}`, + ); + } + const expectedPayload = JSON.stringify(expectedEvent.payload); + const actualPayload = JSON.stringify(actualEvent.payload); + if (expectedPayload !== actualPayload) { + diffs.push(`events[${index}].payload mismatch`); + } + } + + return diffs; +} + +export function diffListMessagesResults( + expected: StateListMessagesResult, + actual: StateListMessagesResult, +): string[] { + const diffs: string[] = []; + + if (expected.total !== actual.total) { + diffs.push(`total mismatch: expected=${expected.total} actual=${actual.total}`); + } + if (expected.nextOffset !== actual.nextOffset) { + diffs.push( + `nextOffset mismatch: expected=${String(expected.nextOffset)} actual=${String(actual.nextOffset)}`, + ); + } + if (expected.messages.length !== actual.messages.length) { + diffs.push( + `messages.length mismatch: expected=${expected.messages.length} actual=${actual.messages.length}`, + ); + } + + const minLength = Math.min(expected.messages.length, actual.messages.length); + for (let index = 0; index < minLength; index += 1) { + const expectedMessage = expected.messages[index]; + const actualMessage = actual.messages[index]; + if (!expectedMessage || !actualMessage) { + continue; + } + const expectedSerialized = JSON.stringify(expectedMessage); + const actualSerialized = JSON.stringify(actualMessage); + if (expectedSerialized !== actualSerialized) { + diffs.push(`messages[${index}] mismatch`); + } + } + + return diffs; +} diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts index cfdc095ea291..2a97c7486a3f 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts @@ -25,7 +25,7 @@ import type { } from "@t3tools/contracts"; import { createLogger } from "./logger"; import type { LiveStoreStateMirror } from "./livestore/liveStoreEngine"; -import { diffStateSnapshots } from "./livestore/parity"; +import { diffCatchUpResults, diffListMessagesResults, diffStateSnapshots } from "./livestore/parity"; import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; interface LiveStoreReadPilotEvents { @@ -424,83 +424,3 @@ export class LiveStoreReadPilotStateSyncEngine void this.mirror.dispose(); } } - -function diffCatchUpResults(expected: StateCatchUpResult, actual: StateCatchUpResult): string[] { - const diffs: string[] = []; - - if (expected.lastStateSeq !== actual.lastStateSeq) { - diffs.push( - `lastStateSeq mismatch: expected=${expected.lastStateSeq} actual=${actual.lastStateSeq}`, - ); - } - - if (expected.events.length !== actual.events.length) { - diffs.push(`events.length mismatch: expected=${expected.events.length} actual=${actual.events.length}`); - } - - const minLength = Math.min(expected.events.length, actual.events.length); - for (let index = 0; index < minLength; index += 1) { - const expectedEvent = expected.events[index]; - const actualEvent = actual.events[index]; - if (!expectedEvent || !actualEvent) { - continue; - } - if (expectedEvent.seq !== actualEvent.seq) { - diffs.push(`events[${index}].seq mismatch: expected=${expectedEvent.seq} actual=${actualEvent.seq}`); - } - if (expectedEvent.eventType !== actualEvent.eventType) { - diffs.push( - `events[${index}].eventType mismatch: expected=${expectedEvent.eventType} actual=${actualEvent.eventType}`, - ); - } - if (expectedEvent.entityId !== actualEvent.entityId) { - diffs.push( - `events[${index}].entityId mismatch: expected=${expectedEvent.entityId} actual=${actualEvent.entityId}`, - ); - } - const expectedPayload = JSON.stringify(expectedEvent.payload); - const actualPayload = JSON.stringify(actualEvent.payload); - if (expectedPayload !== actualPayload) { - diffs.push(`events[${index}].payload mismatch`); - } - } - - return diffs; -} - -function diffListMessagesResults( - expected: StateListMessagesResult, - actual: StateListMessagesResult, -): string[] { - const diffs: string[] = []; - - if (expected.total !== actual.total) { - diffs.push(`total mismatch: expected=${expected.total} actual=${actual.total}`); - } - if (expected.nextOffset !== actual.nextOffset) { - diffs.push( - `nextOffset mismatch: expected=${String(expected.nextOffset)} actual=${String(actual.nextOffset)}`, - ); - } - if (expected.messages.length !== actual.messages.length) { - diffs.push( - `messages.length mismatch: expected=${expected.messages.length} actual=${actual.messages.length}`, - ); - } - - const minLength = Math.min(expected.messages.length, actual.messages.length); - for (let index = 0; index < minLength; index += 1) { - const expectedMessage = expected.messages[index]; - const actualMessage = actual.messages[index]; - if (!expectedMessage || !actualMessage) { - continue; - } - const expectedSerialized = JSON.stringify(expectedMessage); - const actualSerialized = JSON.stringify(actualMessage); - if (expectedSerialized !== actualSerialized) { - diffs.push(`messages[${index}] mismatch`); - } - } - - return diffs; -} diff --git a/apps/server/src/stateSyncEngineShadow.ts b/apps/server/src/stateSyncEngineShadow.ts index 3fd0f6a056c3..6271dcecb79b 100644 --- a/apps/server/src/stateSyncEngineShadow.ts +++ b/apps/server/src/stateSyncEngineShadow.ts @@ -24,7 +24,7 @@ import type { ThreadsUpdateTitleInput, } from "@t3tools/contracts"; import { createLogger } from "./logger"; -import { diffStateSnapshots } from "./livestore/parity"; +import { diffCatchUpResults, diffListMessagesResults, diffStateSnapshots } from "./livestore/parity"; import type { ApplyCheckpointRevertInput, StateSyncEngine } from "./stateSyncEngine"; interface StateSyncEngineShadowEvents { @@ -285,83 +285,3 @@ export class ShadowStateSyncEngine void this.mirror.dispose(); } } - -function diffCatchUpResults(expected: StateCatchUpResult, actual: StateCatchUpResult): string[] { - const diffs: string[] = []; - - if (expected.lastStateSeq !== actual.lastStateSeq) { - diffs.push( - `lastStateSeq mismatch: expected=${expected.lastStateSeq} actual=${actual.lastStateSeq}`, - ); - } - - if (expected.events.length !== actual.events.length) { - diffs.push(`events.length mismatch: expected=${expected.events.length} actual=${actual.events.length}`); - } - - const minLength = Math.min(expected.events.length, actual.events.length); - for (let index = 0; index < minLength; index += 1) { - const expectedEvent = expected.events[index]; - const actualEvent = actual.events[index]; - if (!expectedEvent || !actualEvent) { - continue; - } - if (expectedEvent.seq !== actualEvent.seq) { - diffs.push(`events[${index}].seq mismatch: expected=${expectedEvent.seq} actual=${actualEvent.seq}`); - } - if (expectedEvent.eventType !== actualEvent.eventType) { - diffs.push( - `events[${index}].eventType mismatch: expected=${expectedEvent.eventType} actual=${actualEvent.eventType}`, - ); - } - if (expectedEvent.entityId !== actualEvent.entityId) { - diffs.push( - `events[${index}].entityId mismatch: expected=${expectedEvent.entityId} actual=${actualEvent.entityId}`, - ); - } - const expectedPayload = JSON.stringify(expectedEvent.payload); - const actualPayload = JSON.stringify(actualEvent.payload); - if (expectedPayload !== actualPayload) { - diffs.push(`events[${index}].payload mismatch`); - } - } - - return diffs; -} - -function diffListMessagesResults( - expected: StateListMessagesResult, - actual: StateListMessagesResult, -): string[] { - const diffs: string[] = []; - - if (expected.total !== actual.total) { - diffs.push(`total mismatch: expected=${expected.total} actual=${actual.total}`); - } - if (expected.nextOffset !== actual.nextOffset) { - diffs.push( - `nextOffset mismatch: expected=${String(expected.nextOffset)} actual=${String(actual.nextOffset)}`, - ); - } - if (expected.messages.length !== actual.messages.length) { - diffs.push( - `messages.length mismatch: expected=${expected.messages.length} actual=${actual.messages.length}`, - ); - } - - const minLength = Math.min(expected.messages.length, actual.messages.length); - for (let index = 0; index < minLength; index += 1) { - const expectedMessage = expected.messages[index]; - const actualMessage = actual.messages[index]; - if (!expectedMessage || !actualMessage) { - continue; - } - const expectedSerialized = JSON.stringify(expectedMessage); - const actualSerialized = JSON.stringify(actualMessage); - if (expectedSerialized !== actualSerialized) { - diffs.push(`messages[${index}] mismatch`); - } - } - - return diffs; -} From 9a56a22dcfa260560bf8bbe15f734244cd00cd1c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:51:48 +0000 Subject: [PATCH 40/42] Infer strict livestore mode from read-pilot fallback policy Co-authored-by: Julius Marminge --- .../src/stateSyncEngineLiveStoreReadPilot.ts | 4 ++ apps/server/src/wsServer.test.ts | 38 +++++++++++++++++++ apps/server/src/wsServer.ts | 2 +- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts index 2a97c7486a3f..93580a1ceb1a 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts @@ -302,6 +302,10 @@ export class LiveStoreReadPilotStateSyncEngine }; } + isReadFallbackDisabled(): boolean { + return this.disableDelegateReadFallback; + } + private checkCatchUpParity(raw: StateCatchUpInput, mirrorResult: StateCatchUpResult): void { let delegateResult: StateCatchUpResult; try { diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 0f34c875299d..1a2418158626 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -656,6 +656,44 @@ describe("WebSocket Server", () => { } }); + it("infers strict livestore mode in server.getConfig when fallback is disabled", async () => { + const stateDir = makeTempDir("t3code-ws-config-livestore-infer-state-"); + const persistenceService = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService }); + const mirror = new LiveStoreStateMirror({ storeId: "ws-config-livestore-infer-test" }); + const strictReadPilot = new LiveStoreReadPilotStateSyncEngine({ + delegate: legacy, + mirror, + disableDelegateReadFallback: true, + }); + + try { + server = createTestServer({ + cwd: "/my/workspace", + stateSyncEngine: strictReadPilot, + }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + syncEngineMode: "livestore", + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + }); + } finally { + strictReadPilot.close(); + } + }); + it("reads keybindings from ~/.t3/keybindings.json", async () => { const fakeHome = makeTempDir("t3code-home-"); const configDir = path.join(fakeHome, ".t3"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index fc8caf2370ed..73b3f13e06ba 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -618,7 +618,7 @@ export function createServer(options: ServerOptions) { function inferSyncEngineMode(stateSyncEngine: StateSyncEngine): SyncEngineMode { if (stateSyncEngine instanceof LiveStoreReadPilotStateSyncEngine) { - return "livestore-read-pilot"; + return stateSyncEngine.isReadFallbackDisabled() ? "livestore" : "livestore-read-pilot"; } if (stateSyncEngine instanceof ShadowStateSyncEngine) { return "shadow"; From f0888582cf5d67e6a73bf095f11f60c6195ae6fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:54:26 +0000 Subject: [PATCH 41/42] Remove client-side legacy state-source mode seam Co-authored-by: Julius Marminge --- README.md | 7 +----- SYNC_ENGINE_MIGRATION.md | 5 +--- apps/web/src/routes/__root.tsx | 22 +++--------------- apps/web/src/stateSource.test.ts | 39 +------------------------------- apps/web/src/stateSource.ts | 27 +--------------------- 5 files changed, 7 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 4fb89eeafb2c..a12ac23c8ae9 100644 --- a/README.md +++ b/README.md @@ -138,12 +138,7 @@ Optional diagnostics: - In `livestore-read-pilot` mode, disables delegate read fallback and fails requests when mirror reads fail (strict canary mode). - Read-source/fallback counters are emitted on shutdown via `livestore read pilot metrics` logs. -Web client state-source selection: - -- `VITE_T3CODE_STATE_SOURCE_MODE=legacy-api|livestore-read-pilot` - - Selects the web state-source adapter mode. - - If unset, the web app derives mode from `server.getConfig().syncEngineMode` (`livestore-read-pilot`/`livestore` map to read-pilot source; everything else maps to legacy-api). - - Current behavior is protocol-compatible in both modes (delegates to `api.state.*`) while preserving a client-side seam for future LiveStore-native source wiring. +Web clients now always consume the server-authoritative `api.state.*` stream, and the server mode fully controls LiveStore vs fallback behavior. ## Provider architecture diff --git a/SYNC_ENGINE_MIGRATION.md b/SYNC_ENGINE_MIGRATION.md index 986c2e015271..89f11b189b28 100644 --- a/SYNC_ENGINE_MIGRATION.md +++ b/SYNC_ENGINE_MIGRATION.md @@ -37,10 +37,7 @@ Optional safety gate: - In shadow mode, compares `state.listMessages` mirror output against delegate output. - `T3CODE_LIVESTORE_DISABLE_READ_FALLBACK=1` - In read-pilot mode, disables delegate read fallback for strict mirror-read canary validation. -- `VITE_T3CODE_STATE_SOURCE_MODE=legacy-api|livestore-read-pilot` - - Client-side state-source mode seam. - - If unset, web derives mode from `server.getConfig().syncEngineMode` (`livestore-read-pilot`/`livestore` -> read-pilot source). - - Current implementation is protocol-compatible in both modes. +- Web clients consume the server-authoritative `api.state.*` stream directly; server mode now controls fallback behavior. ## Suggested rollout sequence diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 86e930232a56..2e62415ac81c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -5,7 +5,7 @@ import { useParams, } from "@tanstack/react-router"; import { useEffect, useMemo, useRef } from "react"; -import { QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; +import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { Button } from "../components/ui/button"; @@ -13,12 +13,7 @@ import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; import { isElectron } from "../env"; import { useNativeApi } from "../hooks/useNativeApi"; import { invalidateGitQueries } from "../lib/gitReactQuery"; -import { serverConfigQueryOptions } from "../lib/serverReactQuery"; -import { - createStateSource, - mapSyncEngineModeToStateSourceMode, - resolveStateSourceMode, -} from "../stateSource"; +import { createStateSource } from "../stateSource"; import { useStore } from "../store"; import { onServerWelcome } from "../wsNativeApi"; @@ -135,20 +130,9 @@ function StateSyncRouter() { strict: false, select: (params) => params.threadId, }); - const serverConfigQuery = useQuery(serverConfigQueryOptions(api)); - const stateSourceMode = useMemo(() => { - const rawOverride = import.meta.env.VITE_T3CODE_STATE_SOURCE_MODE as string | undefined; - if (rawOverride && rawOverride.trim().length > 0) { - return resolveStateSourceMode(rawOverride); - } - return mapSyncEngineModeToStateSourceMode(serverConfigQuery.data?.syncEngineMode); - }, [serverConfigQuery.data?.syncEngineMode]); const lastStateSeqRef = useRef(0); const stateQueueRef = useRef(Promise.resolve()); - const stateSource = useMemo( - () => (api ? createStateSource(api, { mode: stateSourceMode }) : null), - [api, stateSourceMode], - ); + const stateSource = useMemo(() => (api ? createStateSource(api) : null), [api]); useEffect(() => { if (!api || !stateSource) return; diff --git a/apps/web/src/stateSource.test.ts b/apps/web/src/stateSource.test.ts index be3e2d62a94e..055d78972f48 100644 --- a/apps/web/src/stateSource.test.ts +++ b/apps/web/src/stateSource.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { NativeApi, StateEvent } from "@t3tools/contracts"; -import { - createStateSource, - mapSyncEngineModeToStateSourceMode, - resolveStateSourceMode, -} from "./stateSource"; +import { createStateSource } from "./stateSource"; function makeApi(): NativeApi { const onStateEvent = vi.fn<(callback: (event: StateEvent) => void) => () => void>((callback) => { @@ -169,7 +165,6 @@ describe("createStateSource", () => { it("delegates bootstrap/catchUp/onEvent to api.state", async () => { const api = makeApi(); const source = createStateSource(api); - expect(source.mode).toBe("legacy-api"); await expect(source.bootstrap()).resolves.toEqual({ projects: [], @@ -200,36 +195,4 @@ describe("createStateSource", () => { expect(api.state.catchUp).toHaveBeenCalledWith({ afterSeq: 1 }); expect(api.state.onEvent).toHaveBeenCalledTimes(1); }); - - it("supports explicit read-pilot mode selection", async () => { - const api = makeApi(); - const source = createStateSource(api, { mode: "livestore-read-pilot" }); - expect(source.mode).toBe("livestore-read-pilot"); - await source.bootstrap(); - expect(api.state.bootstrap).toHaveBeenCalledTimes(1); - }); -}); - -describe("resolveStateSourceMode", () => { - it("defaults to legacy-api for missing/unknown values", () => { - expect(resolveStateSourceMode(undefined)).toBe("legacy-api"); - expect(resolveStateSourceMode("unknown")).toBe("legacy-api"); - }); - - it("accepts livestore-read-pilot value", () => { - expect(resolveStateSourceMode("livestore-read-pilot")).toBe("livestore-read-pilot"); - }); -}); - -describe("mapSyncEngineModeToStateSourceMode", () => { - it("maps read-pilot server mode to read-pilot state source", () => { - expect(mapSyncEngineModeToStateSourceMode("livestore-read-pilot")).toBe("livestore-read-pilot"); - expect(mapSyncEngineModeToStateSourceMode("livestore")).toBe("livestore-read-pilot"); - }); - - it("maps legacy/shadow/undefined server modes to legacy-api source", () => { - expect(mapSyncEngineModeToStateSourceMode("legacy")).toBe("legacy-api"); - expect(mapSyncEngineModeToStateSourceMode("shadow")).toBe("legacy-api"); - expect(mapSyncEngineModeToStateSourceMode(undefined)).toBe("legacy-api"); - }); }); diff --git a/apps/web/src/stateSource.ts b/apps/web/src/stateSource.ts index 04f71ef54f5e..ed541d74a635 100644 --- a/apps/web/src/stateSource.ts +++ b/apps/web/src/stateSource.ts @@ -6,39 +6,14 @@ import type { StateEvent, } from "@t3tools/contracts"; -export type StateSourceMode = "legacy-api" | "livestore-read-pilot"; - export interface StateSource { - mode: StateSourceMode; bootstrap(): Promise; catchUp(input: StateCatchUpInput): Promise; onEvent(listener: (event: StateEvent) => void): () => void; } -export function resolveStateSourceMode(rawMode: string | undefined): StateSourceMode { - if (rawMode === "livestore-read-pilot") { - return "livestore-read-pilot"; - } - return "legacy-api"; -} - -export function mapSyncEngineModeToStateSourceMode( - syncEngineMode: "legacy" | "shadow" | "livestore-read-pilot" | "livestore" | undefined, -): StateSourceMode { - return syncEngineMode === "livestore-read-pilot" || syncEngineMode === "livestore" - ? "livestore-read-pilot" - : "legacy-api"; -} - -export function createStateSource( - api: NativeApi, - options: { - mode?: StateSourceMode; - } = {}, -): StateSource { - const mode = options.mode ?? "legacy-api"; +export function createStateSource(api: NativeApi): StateSource { return { - mode, bootstrap: () => api.state.bootstrap(), catchUp: (input) => api.state.catchUp(input), onEvent: (listener) => api.state.onEvent(listener), From c3975c1b224b9a1a1b631dfb18b9258c3dd4a76b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Feb 2026 12:56:40 +0000 Subject: [PATCH 42/42] Warn when mirror reports unsuccessful state-event writes Co-authored-by: Julius Marminge --- .../stateSyncEngineLiveStoreReadPilot.test.ts | 44 +++++++++++++++++++ .../src/stateSyncEngineLiveStoreReadPilot.ts | 19 +++++--- apps/server/src/stateSyncEngineShadow.test.ts | 30 +++++++++++++ apps/server/src/stateSyncEngineShadow.ts | 15 +++++-- 4 files changed, 100 insertions(+), 8 deletions(-) diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts index f66d7238b043..faa2710e7a76 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.test.ts @@ -287,6 +287,50 @@ describe("LiveStoreReadPilotStateSyncEngine", () => { } }); + it("logs a warning when mirror reports unsuccessful event writes", async () => { + const delegate = new MockDelegateStateSyncEngine(); + const mirror = makeMirrorStub({ + snapshot: { + projects: [], + threads: [], + lastStateSeq: 1, + }, + catchUp: { + events: [], + lastStateSeq: 1, + }, + listMessages: { + messages: [], + total: 0, + nextOffset: null, + }, + }); + vi.mocked(mirror.mirrorStateEvent).mockResolvedValue(false); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const engine = new LiveStoreReadPilotStateSyncEngine({ + delegate, + mirror, + }); + + try { + delegate.emitStateEvent({ + seq: 1, + eventType: "project.upsert", + entityId: "project-1", + payload: {}, + createdAt: "2026-02-20T00:00:00.000Z", + }); + await Promise.resolve(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("mirror state event in read pilot reported unsuccessful write"), + ); + } finally { + warnSpy.mockRestore(); + engine.close(); + } + }); + it("runs bootstrap parity check against delegate when enabled", () => { const delegate = new MockDelegateStateSyncEngine(); delegate.loadSnapshotMock.mockReturnValue({ diff --git a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts index 93580a1ceb1a..45f3a2d5567d 100644 --- a/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts +++ b/apps/server/src/stateSyncEngineLiveStoreReadPilot.ts @@ -92,12 +92,21 @@ export class LiveStoreReadPilotStateSyncEngine this.disableDelegateReadFallback = options.disableDelegateReadFallback ?? false; this.unsubscribeDelegate = this.delegate.onStateEvent((event) => { this.emit("stateEvent", event); - void this.mirror.mirrorStateEvent(event).catch((error) => { - this.logger.warn("failed to mirror state event in read pilot", { - error, - seq: event.seq, + void this.mirror + .mirrorStateEvent(event) + .then((mirrored) => { + if (mirrored === false) { + this.logger.warn("mirror state event in read pilot reported unsuccessful write", { + seq: event.seq, + }); + } + }) + .catch((error) => { + this.logger.warn("failed to mirror state event in read pilot", { + error, + seq: event.seq, + }); }); - }); }); } diff --git a/apps/server/src/stateSyncEngineShadow.test.ts b/apps/server/src/stateSyncEngineShadow.test.ts index 0426b26761c1..756dbff9a3c3 100644 --- a/apps/server/src/stateSyncEngineShadow.test.ts +++ b/apps/server/src/stateSyncEngineShadow.test.ts @@ -143,6 +143,36 @@ describe("ShadowStateSyncEngine", () => { } }); + it("logs a warning when mirror reports unsuccessful writes", async () => { + const stateDir = makeTempDir("t3code-shadow-unsuccessful-write-state-"); + const projectDir = makeTempDir("t3code-shadow-unsuccessful-write-project-"); + const service = new PersistenceService({ + dbPath: path.join(stateDir, "state.sqlite"), + }); + const legacy = new LegacyStateSyncEngine({ persistenceService: service }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const mirror: StateEventMirror = { + mirrorStateEvent: async () => false, + dispose: async () => undefined, + }; + const shadow = new ShadowStateSyncEngine({ + delegate: legacy, + mirror, + }); + + try { + shadow.addProject({ cwd: projectDir }); + await Promise.resolve(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("livestore shadow mirror reported unsuccessful write"), + ); + } finally { + warnSpy.mockRestore(); + shadow.close(); + service.close(); + } + }); + it("keeps LiveStore mirror snapshot in parity with delegate writes", async () => { const stateDir = makeTempDir("t3code-shadow-parity-state-"); const projectDir = makeTempDir("t3code-shadow-parity-project-"); diff --git a/apps/server/src/stateSyncEngineShadow.ts b/apps/server/src/stateSyncEngineShadow.ts index 6271dcecb79b..aff390139967 100644 --- a/apps/server/src/stateSyncEngineShadow.ts +++ b/apps/server/src/stateSyncEngineShadow.ts @@ -72,9 +72,18 @@ export class ShadowStateSyncEngine this.enableListMessagesParityCheck = options.enableListMessagesParityCheck ?? false; this.unsubscribeDelegate = this.delegate.onStateEvent((event) => { this.emit("stateEvent", event); - void this.mirror.mirrorStateEvent(event).catch((error) => { - this.logger.warn("livestore shadow mirror failed", { error, seq: event.seq }); - }); + void this.mirror + .mirrorStateEvent(event) + .then((mirrored) => { + if (mirrored === false) { + this.logger.warn("livestore shadow mirror reported unsuccessful write", { + seq: event.seq, + }); + } + }) + .catch((error) => { + this.logger.warn("livestore shadow mirror failed", { error, seq: event.seq }); + }); }); }