diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index 4514c20e8564..17885e2ad255 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -1,7 +1,6 @@ import { ORCHESTRATION_CACHE_SCHEMA_VERSION, ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, - StoredOrchestrationShellSnapshot, } from "@t3tools/client-runtime/platform"; import { CommandId, @@ -17,16 +16,11 @@ import { import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Logger from "effect/Logger"; -import * as Schema from "effect/Schema"; -import { vi } from "vite-plus/test"; -import * as Deferred from "effect/Deferred"; import * as Option from "effect/Option"; import * as MobileDatabase from "../persistence/mobile-database"; import { make } from "./environment-cache-store"; -import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); const PROJECT_ID = ProjectId.make("project-1"); @@ -408,258 +402,3 @@ describe("mobile SQLite environment cache store", () => { }), ); }); - -describe("cooperative shell cache encoding", () => { - // A fresh encoder per call keeps these cases cold; the cache has its own cases below. - const encodeStoredShellSnapshot = (stored: typeof StoredOrchestrationShellSnapshot.Type) => - makeStoredShellSnapshotEncoder()(stored); - const encodeOriginal = Schema.encodeEffect( - Schema.fromJsonString(StoredOrchestrationShellSnapshot), - ); - const stored = { - schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, - environmentId: ENVIRONMENT_ID, - snapshot: { - ...SHELL_SNAPSHOT, - projects: SHELL_SNAPSHOT.projects.map((project) => ({ - ...project, - title: " Project ", - unexpected: "drop this field", - })), - threads: Array.from({ length: 64 }, (_, index) => ({ - ...SHELL_SNAPSHOT.threads[0]!, - id: ThreadId.make(`thread-${index}`), - branch: " main ", - activityRunStartedAt: index % 2 === 0 ? NOW : null, - unsettledAt: index % 2 === 0 ? NOW : null, - modelSelection: { - instanceId: PROVIDER_INSTANCE_ID, - model: " gpt-5.4 ", - unexpected: "drop this field", - }, - unexpected: "drop this field", - })), - archivedThreads: [{ ...SHELL_SNAPSHOT.threads[0]!, archivedAt: NOW }], - unexpected: "drop this field", - }, - unexpected: "drop this field", - }; - - it.effect("preserves exact JSON, transforms, nulls, dates, and unknown-field stripping", () => - Effect.gen(function* () { - const expected = yield* encodeOriginal(stored); - const actual = yield* encodeStoredShellSnapshot(stored); - expect(actual).toBe(expected); - const parsed = JSON.parse(actual); - expect(parsed).not.toHaveProperty("unexpected"); - expect(parsed.snapshot).not.toHaveProperty("unexpected"); - expect(parsed.snapshot.projects[0]).not.toHaveProperty("unexpected"); - expect(parsed.snapshot.projects[0].title).toBe("Project"); - expect(parsed.snapshot.threads[0]).not.toHaveProperty("unexpected"); - expect(parsed.snapshot.threads[0].branch).toBe("main"); - expect(parsed.snapshot.threads[0].modelSelection).toEqual({ - instanceId: "codex", - model: "gpt-5.4", - }); - expect(parsed.snapshot.threads[0].activityRunStartedAt).toBe(DateTime.formatIso(NOW)); - expect(parsed.snapshot.threads[1].activityRunStartedAt).toBeNull(); - expect(parsed.snapshot.archivedThreads[0].archivedAt).toBe(DateTime.formatIso(NOW)); - }), - ); - - it.effect("preserves optional fields and dates across chunk boundaries and archived rows", () => - Effect.gen(function* () { - const base = SHELL_SNAPSHOT.threads[0]!; - const { titleRegeneration: _, unsettledAt: __, ...withoutOptional } = base; - const row = (index: number) => - index % 3 === 0 - ? { ...withoutOptional, id: ThreadId.make(`row-${index}`) } - : { - ...base, - id: ThreadId.make(`row-${index}`), - snoozedUntil: DateTime.add(NOW, { minutes: index }), - pinnedAt: index % 2 === 0 ? null : NOW, - }; - const mixed = { - ...stored, - snapshot: { - ...stored.snapshot, - threads: Array.from({ length: 65 }, (_, index) => row(index)), - archivedThreads: Array.from({ length: 33 }, (_, index) => ({ - ...row(index + 100), - archivedAt: DateTime.add(NOW, { hours: index }), - })), - }, - }; - const actual = yield* encodeStoredShellSnapshot(mixed); - expect(actual).toBe(yield* encodeOriginal(mixed)); - const parsed = JSON.parse(actual); - expect(parsed.snapshot.threads).toHaveLength(65); - expect(parsed.snapshot.threads[63]).not.toHaveProperty("titleRegeneration"); - expect(parsed.snapshot.threads[63]).not.toHaveProperty("unsettledAt"); - expect(parsed.snapshot.threads[64].snoozedUntil).toBe( - DateTime.formatIso(DateTime.add(NOW, { minutes: 64 })), - ); - expect(parsed.snapshot.archivedThreads).toHaveLength(33); - expect(parsed.snapshot.archivedThreads[32].archivedAt).toBe( - DateTime.formatIso(DateTime.add(NOW, { hours: 32 })), - ); - }), - ); - - it.effect("yields to a host timer between every 32-row chunk", () => - Effect.gen(function* () { - const setTimer = vi.spyOn(globalThis, "setTimeout"); - try { - yield* encodeStoredShellSnapshot({ - ...stored, - snapshot: { - ...stored.snapshot, - threads: Array.from({ length: 65 }, () => ({ ...stored.snapshot.threads[0]! })), - archivedThreads: Array.from({ length: 33 }, () => ({ - ...stored.snapshot.archivedThreads[0]!, - })), - }, - }); - // Five row chunks (3 active + 2 archived): four yields between them, one before the envelope. - expect(setTimer).toHaveBeenCalledTimes(5); - } finally { - vi.restoreAllMocks(); - } - }), - ); - - it.effect("still rejects invalid active and archived rows after an encoding chunk yields", () => - Effect.gen(function* () { - const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 }; - const invalidActive = { - ...stored, - snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] }, - }; - const invalidArchived = { - ...stored, - snapshot: { ...stored.snapshot, archivedThreads: [invalidRow] }, - }; - const invalidEnvelope = { ...stored, snapshot: { ...stored.snapshot, snapshotSequence: -1 } }; - expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidActive))).toBe(true); - expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidArchived))).toBe(true); - expect(yield* Effect.isFailure(encodeOriginal(invalidEnvelope))).toBe(true); - expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidEnvelope))).toBe(true); - }), - ); - - it.effect("keeps concurrent saves independent", () => - Effect.gen(function* () { - const other = { - ...stored, - environmentId: EnvironmentId.make("environment-2"), - snapshot: { - ...stored.snapshot, - threads: stored.snapshot.threads.slice(0, 40).map((thread) => ({ - ...thread, - title: `Other ${thread.id}`, - })), - archivedThreads: [], - }, - }; - const [first, second] = yield* Effect.all( - [encodeStoredShellSnapshot(stored), encodeStoredShellSnapshot(other)], - { concurrency: "unbounded" }, - ); - expect(first).toBe(yield* encodeOriginal(stored)); - expect(second).toBe(yield* encodeOriginal(other)); - }), - ); - - it.effect("cancels the pending host timer when encoding is interrupted", () => - Effect.gen(function* () { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const fakeSetTimeout = globalThis.setTimeout; - const scheduled = yield* Deferred.make>(); - vi.spyOn(globalThis, "setTimeout").mockImplementation((...args) => { - const timer = fakeSetTimeout(...args); - Deferred.doneUnsafe(scheduled, Effect.succeed(timer)); - return timer; - }); - const clearTimer = vi.spyOn(globalThis, "clearTimeout"); - const fiber = yield* Effect.forkScoped( - Effect.suspend(() => encodeStoredShellSnapshot(stored)), - ); - try { - const pendingTimer = yield* Deferred.await(scheduled); - expect(vi.getTimerCount()).toBe(1); - yield* Fiber.interrupt(fiber); - expect(clearTimer).toHaveBeenCalledWith(pendingTimer); - expect(vi.getTimerCount()).toBe(0); - } finally { - yield* Fiber.interrupt(fiber); - vi.restoreAllMocks(); - vi.useRealTimers(); - } - }), - ); - - it.effect("reuses encoded rows across saves without changing the payload", () => - Effect.gen(function* () { - const encode = makeStoredShellSnapshotEncoder(); - expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored)); - - const setTimer = vi.spyOn(globalThis, "setTimeout"); - try { - // Same row references, new envelope: no row work, so no host yields. - const resaved = { - ...stored, - snapshot: { ...stored.snapshot, snapshotSequence: 4 }, - }; - expect(yield* encode(resaved)).toBe(yield* encodeOriginal(resaved)); - expect(setTimer).not.toHaveBeenCalled(); - } finally { - vi.restoreAllMocks(); - } - }), - ); - - it.effect("encodes replaced, added, and moved-to-archive rows on a warm encoder", () => - Effect.gen(function* () { - const encode = makeStoredShellSnapshotEncoder(); - yield* encode(stored); - const [first, second, ...rest] = stored.snapshot.threads; - const next = { - ...stored, - snapshot: { - ...stored.snapshot, - threads: [ - { ...first!, title: "Renamed", itemCount: 9 }, - ...rest, - { ...first!, id: ThreadId.make("thread-new"), branch: " feature " }, - ], - archivedThreads: [ - ...stored.snapshot.archivedThreads, - { ...second!, archivedAt: DateTime.add(NOW, { hours: 1 }) }, - ], - }, - }; - const actual = yield* encode(next); - expect(actual).toBe(yield* encodeOriginal(next)); - const parsed = JSON.parse(actual); - expect(parsed.snapshot.threads[0].title).toBe("Renamed"); - expect(parsed.snapshot.threads.at(-1).branch).toBe("feature"); - expect(parsed.snapshot.archivedThreads).toHaveLength(2); - }), - ); - - it.effect("does not cache rows from a failed encode", () => - Effect.gen(function* () { - const encode = makeStoredShellSnapshotEncoder(); - const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 }; - const invalid = { - ...stored, - snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] }, - }; - expect(yield* Effect.isFailure(encode(invalid))).toBe(true); - expect(yield* Effect.isFailure(encode(invalid))).toBe(true); - // Valid rows from the failed save still encode correctly afterwards. - expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored)); - }), - ); -}); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 653aded1657d..682cb6383f85 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -3,6 +3,7 @@ import { ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, StoredOrchestrationThreadSnapshot, Persistence, + makeStoredShellSnapshotEncoder, } from "@t3tools/client-runtime/platform"; import { type EnvironmentId, ServerConfig, VcsListRefsResult } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -11,7 +12,6 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; -import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding"; import { attachProjectFaviconDatabase, projectFaviconDatabaseCache, diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 8be169fccd45..e56bf7b7b89e 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -15,6 +15,7 @@ import { setRoutesInCatalog, replaceCatalogValue, Persistence, + makeStoredShellSnapshotEncoder, } from "@t3tools/client-runtime/platform"; import { TokenStore } from "@t3tools/client-runtime/authorization"; import { @@ -68,7 +69,6 @@ const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDoc const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson); -const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson); @@ -601,6 +601,25 @@ export function makeBrowserGitHubRoutingPermissions( }); } +// A posted message resumes in a fresh task, so input and rendering get a turn between +// encoding chunks. A zero-delay timer would not do: browsers clamp nested timers to +// 4 ms and throttle them to once a second or less in a background tab. +const yieldToBrowserTask = Effect.callback((resume) => { + const { port1, port2 } = new MessageChannel(); + port1.addEventListener( + "message", + () => { + port1.close(); + resume(Effect.void); + }, + { once: true }, + ); + port1.start(); + port2.postMessage(null); + // A closed port delivers nothing, so interruption needs no listener bookkeeping. + return Effect.sync(() => port1.close()); +}); + export const layer = Layer.effectContext( Effect.gen(function* () { const { handle: database } = yield* Effect.acquireRelease( @@ -609,6 +628,10 @@ export const layer = Layer.effectContext( ); const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); const githubRoutingPermissions = makeBrowserGitHubRoutingPermissions(); + // Remembers each thread row's encoding, so a save only encodes rows that changed. + const encodeStoredShellSnapshot = makeStoredShellSnapshotEncoder({ + yieldToHost: yieldToBrowserTask, + }); const targetStore = Persistence.ConnectionTargetStore.of({ list: catalog.read.pipe( diff --git a/packages/client-runtime/src/platform/index.ts b/packages/client-runtime/src/platform/index.ts index 8480b2b142f1..18efb9be18bc 100644 --- a/packages/client-runtime/src/platform/index.ts +++ b/packages/client-runtime/src/platform/index.ts @@ -3,5 +3,6 @@ export * as Persistence from "./persistence.ts"; // Flat so consumers' inferred types can name it. export { ConnectionPersistenceError } from "./persistence.ts"; export * from "./orchestrationCache.ts"; +export * from "./shellCacheEncoding.ts"; export * as PlatformConnectionSource from "./source.ts"; export * from "./storageDocument.ts"; diff --git a/packages/client-runtime/src/platform/shellCacheEncoding.test.ts b/packages/client-runtime/src/platform/shellCacheEncoding.test.ts new file mode 100644 index 000000000000..f1a393fb93bc --- /dev/null +++ b/packages/client-runtime/src/platform/shellCacheEncoding.test.ts @@ -0,0 +1,348 @@ +import { + CommandId, + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationV2ShellSnapshot, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; +import { vi } from "vite-plus/test"; +import * as Deferred from "effect/Deferred"; + +import { + ORCHESTRATION_CACHE_SCHEMA_VERSION, + StoredOrchestrationShellSnapshot, +} from "./orchestrationCache.ts"; +import { makeStoredShellSnapshotEncoder } from "./shellCacheEncoding.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-1"); +const PROVIDER_INSTANCE_ID = ProviderInstanceId.make("codex"); +const NOW = DateTime.makeUnsafe("2026-07-29T12:00:00.000Z"); +const SHELL_SNAPSHOT: OrchestrationV2ShellSnapshot = { + schemaVersion: 1, + snapshotSequence: 3, + projects: [ + { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-29T12:00:00.000Z", + updatedAt: "2026-07-29T12:00:00.000Z", + }, + ], + threads: [ + { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + providerInstanceId: PROVIDER_INSTANCE_ID, + modelSelection: { + instanceId: PROVIDER_INSTANCE_ID, + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + rootThreadId: THREAD_ID, + parentThreadId: null, + relationshipToParent: null, + }, + forkedFrom: null, + createdBy: "user", + creationSource: "mobile", + latestRunId: null, + activeRunId: null, + status: "idle", + pendingRuntimeRequest: null, + latestVisibleMessage: null, + latestUserMessageAt: NOW, + activityRunStartedAt: NOW, + activityRunStatus: "running", + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: NOW, + unsettledAt: NOW, + lastVisitedAt: null, + titleRegeneration: { + requestId: CommandId.make("title-regeneration-1"), + startedAt: NOW, + }, + deletedAt: null, + }, + ], + archivedThreads: [], +}; + +describe("cooperative shell cache encoding", () => { + // A fresh encoder per call keeps these cases cold; the cache has its own cases below. + const encodeStoredShellSnapshot = (stored: typeof StoredOrchestrationShellSnapshot.Type) => + makeStoredShellSnapshotEncoder()(stored); + const encodeOriginal = Schema.encodeEffect( + Schema.fromJsonString(StoredOrchestrationShellSnapshot), + ); + const stored = { + schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + environmentId: ENVIRONMENT_ID, + snapshot: { + ...SHELL_SNAPSHOT, + projects: SHELL_SNAPSHOT.projects.map((project) => ({ + ...project, + title: " Project ", + unexpected: "drop this field", + })), + threads: Array.from({ length: 64 }, (_, index) => ({ + ...SHELL_SNAPSHOT.threads[0]!, + id: ThreadId.make(`thread-${index}`), + branch: " main ", + activityRunStartedAt: index % 2 === 0 ? NOW : null, + unsettledAt: index % 2 === 0 ? NOW : null, + modelSelection: { + instanceId: PROVIDER_INSTANCE_ID, + model: " gpt-5.4 ", + unexpected: "drop this field", + }, + unexpected: "drop this field", + })), + archivedThreads: [{ ...SHELL_SNAPSHOT.threads[0]!, archivedAt: NOW }], + unexpected: "drop this field", + }, + unexpected: "drop this field", + }; + + it.effect("preserves exact JSON, transforms, nulls, dates, and unknown-field stripping", () => + Effect.gen(function* () { + const expected = yield* encodeOriginal(stored); + const actual = yield* encodeStoredShellSnapshot(stored); + expect(actual).toBe(expected); + const parsed = JSON.parse(actual); + expect(parsed).not.toHaveProperty("unexpected"); + expect(parsed.snapshot).not.toHaveProperty("unexpected"); + expect(parsed.snapshot.projects[0]).not.toHaveProperty("unexpected"); + expect(parsed.snapshot.projects[0].title).toBe("Project"); + expect(parsed.snapshot.threads[0]).not.toHaveProperty("unexpected"); + expect(parsed.snapshot.threads[0].branch).toBe("main"); + expect(parsed.snapshot.threads[0].modelSelection).toEqual({ + instanceId: "codex", + model: "gpt-5.4", + }); + expect(parsed.snapshot.threads[0].activityRunStartedAt).toBe(DateTime.formatIso(NOW)); + expect(parsed.snapshot.threads[1].activityRunStartedAt).toBeNull(); + expect(parsed.snapshot.archivedThreads[0].archivedAt).toBe(DateTime.formatIso(NOW)); + }), + ); + + it.effect("preserves optional fields and dates across chunk boundaries and archived rows", () => + Effect.gen(function* () { + const base = SHELL_SNAPSHOT.threads[0]!; + const { titleRegeneration: _, unsettledAt: __, ...withoutOptional } = base; + const row = (index: number) => + index % 3 === 0 + ? { ...withoutOptional, id: ThreadId.make(`row-${index}`) } + : { + ...base, + id: ThreadId.make(`row-${index}`), + snoozedUntil: DateTime.add(NOW, { minutes: index }), + pinnedAt: index % 2 === 0 ? null : NOW, + }; + const mixed = { + ...stored, + snapshot: { + ...stored.snapshot, + threads: Array.from({ length: 65 }, (_, index) => row(index)), + archivedThreads: Array.from({ length: 33 }, (_, index) => ({ + ...row(index + 100), + archivedAt: DateTime.add(NOW, { hours: index }), + })), + }, + }; + const actual = yield* encodeStoredShellSnapshot(mixed); + expect(actual).toBe(yield* encodeOriginal(mixed)); + const parsed = JSON.parse(actual); + expect(parsed.snapshot.threads).toHaveLength(65); + expect(parsed.snapshot.threads[63]).not.toHaveProperty("titleRegeneration"); + expect(parsed.snapshot.threads[63]).not.toHaveProperty("unsettledAt"); + expect(parsed.snapshot.threads[64].snoozedUntil).toBe( + DateTime.formatIso(DateTime.add(NOW, { minutes: 64 })), + ); + expect(parsed.snapshot.archivedThreads).toHaveLength(33); + expect(parsed.snapshot.archivedThreads[32].archivedAt).toBe( + DateTime.formatIso(DateTime.add(NOW, { hours: 32 })), + ); + }), + ); + + it.effect("yields to a host timer between every 32-row chunk", () => + Effect.gen(function* () { + const setTimer = vi.spyOn(globalThis, "setTimeout"); + try { + yield* encodeStoredShellSnapshot({ + ...stored, + snapshot: { + ...stored.snapshot, + threads: Array.from({ length: 65 }, () => ({ ...stored.snapshot.threads[0]! })), + archivedThreads: Array.from({ length: 33 }, () => ({ + ...stored.snapshot.archivedThreads[0]!, + })), + }, + }); + // Five row chunks (3 active + 2 archived): four yields between them, one before the envelope. + expect(setTimer).toHaveBeenCalledTimes(5); + } finally { + vi.restoreAllMocks(); + } + }), + ); + + it.effect("still rejects invalid active and archived rows after an encoding chunk yields", () => + Effect.gen(function* () { + const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 }; + const invalidActive = { + ...stored, + snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] }, + }; + const invalidArchived = { + ...stored, + snapshot: { ...stored.snapshot, archivedThreads: [invalidRow] }, + }; + const invalidEnvelope = { ...stored, snapshot: { ...stored.snapshot, snapshotSequence: -1 } }; + expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidActive))).toBe(true); + expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidArchived))).toBe(true); + expect(yield* Effect.isFailure(encodeOriginal(invalidEnvelope))).toBe(true); + expect(yield* Effect.isFailure(encodeStoredShellSnapshot(invalidEnvelope))).toBe(true); + }), + ); + + it.effect("keeps concurrent saves independent", () => + Effect.gen(function* () { + const other = { + ...stored, + environmentId: EnvironmentId.make("environment-2"), + snapshot: { + ...stored.snapshot, + threads: stored.snapshot.threads.slice(0, 40).map((thread) => ({ + ...thread, + title: `Other ${thread.id}`, + })), + archivedThreads: [], + }, + }; + const [first, second] = yield* Effect.all( + [encodeStoredShellSnapshot(stored), encodeStoredShellSnapshot(other)], + { concurrency: "unbounded" }, + ); + expect(first).toBe(yield* encodeOriginal(stored)); + expect(second).toBe(yield* encodeOriginal(other)); + }), + ); + + it.effect("cancels the pending host timer when encoding is interrupted", () => + Effect.gen(function* () { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const fakeSetTimeout = globalThis.setTimeout; + const scheduled = yield* Deferred.make>(); + vi.spyOn(globalThis, "setTimeout").mockImplementation((...args) => { + // @effect-diagnostics-next-line globalTimers:off - Observes the encoder's own host timer. + const timer = fakeSetTimeout(...args); + Deferred.doneUnsafe(scheduled, Effect.succeed(timer)); + return timer; + }); + const clearTimer = vi.spyOn(globalThis, "clearTimeout"); + const fiber = yield* Effect.forkScoped( + Effect.suspend(() => encodeStoredShellSnapshot(stored)), + ); + try { + const pendingTimer = yield* Deferred.await(scheduled); + expect(vi.getTimerCount()).toBe(1); + yield* Fiber.interrupt(fiber); + expect(clearTimer).toHaveBeenCalledWith(pendingTimer); + expect(vi.getTimerCount()).toBe(0); + } finally { + yield* Fiber.interrupt(fiber); + vi.restoreAllMocks(); + vi.useRealTimers(); + } + }), + ); + + it.effect("reuses encoded rows across saves without changing the payload", () => + Effect.gen(function* () { + const encode = makeStoredShellSnapshotEncoder(); + expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored)); + + const setTimer = vi.spyOn(globalThis, "setTimeout"); + try { + // Same row references, new envelope: no row work, so no host yields. + const resaved = { + ...stored, + snapshot: { ...stored.snapshot, snapshotSequence: 4 }, + }; + expect(yield* encode(resaved)).toBe(yield* encodeOriginal(resaved)); + expect(setTimer).not.toHaveBeenCalled(); + } finally { + vi.restoreAllMocks(); + } + }), + ); + + it.effect("encodes replaced, added, and moved-to-archive rows on a warm encoder", () => + Effect.gen(function* () { + const encode = makeStoredShellSnapshotEncoder(); + yield* encode(stored); + const [first, second, ...rest] = stored.snapshot.threads; + const next = { + ...stored, + snapshot: { + ...stored.snapshot, + threads: [ + { ...first!, title: "Renamed", itemCount: 9 }, + ...rest, + { ...first!, id: ThreadId.make("thread-new"), branch: " feature " }, + ], + archivedThreads: [ + ...stored.snapshot.archivedThreads, + { ...second!, archivedAt: DateTime.add(NOW, { hours: 1 }) }, + ], + }, + }; + const actual = yield* encode(next); + expect(actual).toBe(yield* encodeOriginal(next)); + const parsed = JSON.parse(actual); + expect(parsed.snapshot.threads[0].title).toBe("Renamed"); + expect(parsed.snapshot.threads.at(-1).branch).toBe("feature"); + expect(parsed.snapshot.archivedThreads).toHaveLength(2); + }), + ); + + it.effect("does not cache rows from a failed encode", () => + Effect.gen(function* () { + const encode = makeStoredShellSnapshotEncoder(); + const invalidRow = { ...stored.snapshot.threads[0]!, itemCount: -1 }; + const invalid = { + ...stored, + snapshot: { ...stored.snapshot, threads: [...stored.snapshot.threads, invalidRow] }, + }; + expect(yield* Effect.isFailure(encode(invalid))).toBe(true); + expect(yield* Effect.isFailure(encode(invalid))).toBe(true); + // Valid rows from the failed save still encode correctly afterwards. + expect(yield* encode(stored)).toBe(yield* encodeOriginal(stored)); + }), + ); +}); diff --git a/apps/mobile/src/connection/shell-cache-encoding.ts b/packages/client-runtime/src/platform/shellCacheEncoding.ts similarity index 78% rename from apps/mobile/src/connection/shell-cache-encoding.ts rename to packages/client-runtime/src/platform/shellCacheEncoding.ts index e5b4105ee438..da6b3c98617f 100644 --- a/apps/mobile/src/connection/shell-cache-encoding.ts +++ b/packages/client-runtime/src/platform/shellCacheEncoding.ts @@ -1,13 +1,17 @@ -import { StoredOrchestrationShellSnapshot } from "@t3tools/client-runtime/platform"; import { OrchestrationV2ThreadShellJson } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { StoredOrchestrationShellSnapshot } from "./orchestrationCache.ts"; + const ROWS_PER_CHUNK = 32; -// Effect's cooperative yield resumes through microtasks on React Native. A host -// timer lets input and rendering run between chunks of shell rows instead. -const yieldToHost = Effect.callback((resume) => { +// Effect's own yield resumes through `setImmediate` where the host defines it and a +// zero-delay timer otherwise. React Native's `setImmediate` is a microtask shim, so +// nothing from the host runs before the fiber resumes. A timer yields on every host; +// a browser can pass a yield that its timer clamping and throttling do not slow. +const yieldToHostTimer = Effect.callback((resume) => { + // @effect-diagnostics-next-line globalTimersInEffect:off - A host timer, not Effect.sleep, is the point. const timer = setTimeout(() => resume(Effect.void), 0); return Effect.sync(() => clearTimeout(timer)); }); @@ -38,8 +42,13 @@ const encodeEnvelope = Schema.encodeEffect( * Shell rows are immutable and the shell reducer keeps unchanged rows by * reference, so each encoder remembers the canonical encoding of rows it has * already encoded successfully and only runs the row codec for new rows. + * + * `yieldToHost` defaults to a zero-delay timer. */ -export function makeStoredShellSnapshotEncoder() { +export function makeStoredShellSnapshotEncoder( + options: { readonly yieldToHost?: Effect.Effect } = {}, +) { + const yieldToHost = options.yieldToHost ?? yieldToHostTimer; const encodedRows = new WeakMap(); return Effect.fnUntraced(function* (stored: typeof StoredOrchestrationShellSnapshot.Type) {