From 7a9e6e03bc54ee938b5ae2ce40620f60de305d2e Mon Sep 17 00:00:00 2001 From: snipemanmike Date: Fri, 10 Jul 2026 00:57:38 -0700 Subject: [PATCH] feat(sync): bounded tail-first thread sync (v2) with media externalization and transport negotiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a large thread still transfers the entire thread body — every message, activity, and inline base64 blob since the thread began. On cellular this never completes for media-heavy threads, and parsing the resulting payload can OOM-kill the mobile app outright (observed: a 268MB thread snapshot). #3719 moved the snapshot off the socket; this change bounds what is transferred at all. Measured on a physical Android device over Tailscale: - One media-heavy thread's snapshot was ~25MB on the wire; gzip only buys 1.6x on inline base64. With the bounded tail it renders in seconds over 5G. - Sequence-cursor reconnects: 21,528KB re-sent per reconnect before, 36-54KB after (~400x). What this adds, all capability-gated so old peers are unaffected: - subscribeThreadV2: a small thread head (metadata, latest turn, session, active plan, pending approval/input requests, counts) plus the last 32 messages / 128 compact activities under a 512KiB inline budget, emitted as <=256KiB chunks with keepalives between them, then live events after the snapshot watermark. Catch-up replays are epoch-checked and bounded to the watermark; a bounded (2048) live buffer signals an explicit resync instead of queueing unboundedly. - getThreadHistoryPage: keyset pagination for older messages and activities (scroll-up on mobile), invalidated across thread.reverted via a history epoch so pages never mix pre/post-revert history. - Activity payload externalization: base64 data-URLs and oversized payloads are stored as content-addressed attachments served by the existing /api/assets route; the wire carries references and mobile renders tap-to-load placeholders. (Message attachments already work this way — activity payloads were the unbounded route.) - Transport negotiation: the environment descriptor advertises rpcTransports and threadSyncVersions (with decode defaults, so new clients read old descriptors); /ws stays byte-for-byte plain JSON and an optional gzip transport lives at /ws-compressed behind an optional RpcCompressionCodec (defaults to null; only opted-in platforms provide one). Cached-token reuse treats the negotiation probe as best-effort so offline reconnects keep working. - Client: a distinct windowed thread state (never passed through APIs typed as a complete OrchestrationThread), atomic snapshot staging committed together with its cursor, resyncs that restart the subscription with a logged reason, and thread caches that store either a legacy detail snapshot or a v2 window (older records evict as cache misses). Array-by-copy methods are avoided in mobile-bound code: Hermes on current retail devices lacks toSorted and fails as a silent fiber defect. Tests: v2 contract decode/round-trip, transactional tail and keyset page queries with revert invalidation, wire round-trip -> staging integration, windowed merge/revert reducers, mobile feed windowing, and an opt-in real-data replay suite (T3_THREAD_SYNC_REALDATA_DB) that replays production-scale threads through the pipeline. Co-Authored-By: Claude Fable 5 --- apps/mobile/package.json | 2 + .../src/connection/environment-cache-store.ts | 10 +- .../features/threads/ThreadDetailScreen.tsx | 6 + .../src/features/threads/ThreadFeed.tsx | 92 ++- .../features/threads/ThreadRouteScreen.tsx | 3 + .../src/features/threads/thread-work-log.tsx | 61 +- apps/mobile/src/lib/runtime.ts | 15 +- apps/mobile/src/lib/threadActivity.test.ts | 45 ++ apps/mobile/src/lib/threadActivity.ts | 36 +- .../src/environment/ServerEnvironment.ts | 5 + .../Layers/OrchestrationEngine.test.ts | 3 + .../Layers/OrchestrationEngine.ts | 7 + .../Layers/ThreadSyncQuery.realdata.test.ts | 180 +++++ .../Layers/ThreadSyncQuery.test.ts | 106 +++ .../orchestration/Layers/ThreadSyncQuery.ts | 478 ++++++++++++++ .../Services/OrchestrationEngine.ts | 15 + .../orchestration/Services/ThreadSyncQuery.ts | 46 ++ .../src/orchestration/ThreadSyncWire.ts | 348 ++++++++++ apps/server/src/orchestration/runtimeLayer.ts | 2 + .../Layers/OrchestrationEventStore.ts | 88 +++ .../Services/OrchestrationEventStore.ts | 16 + .../src/relay/AgentAwarenessRelay.test.ts | 2 + apps/server/src/server.test.ts | 20 +- apps/server/src/serverRuntimeStartup.test.ts | 3 + apps/server/src/ws.ts | 438 ++++++++++-- apps/web/package.json | 2 + apps/web/src/connection/storage.ts | 42 +- .../environments/primary/bootstrap.test.ts | 2 + apps/web/src/lib/runtime.ts | 15 + .../src/authorization/layer.test.ts | 12 +- .../src/authorization/service.ts | 58 +- .../client-runtime/src/connection/model.ts | 4 +- .../src/connection/resolver.test.ts | 2 + .../client-runtime/src/connection/resolver.ts | 39 +- .../client-runtime/src/environment/index.ts | 1 + .../src/environment/transport.ts | 40 ++ .../src/platform/persistence.ts | 17 +- packages/client-runtime/src/rpc/client.ts | 13 +- .../client-runtime/src/rpc/session.test.ts | 2 + packages/client-runtime/src/rpc/session.ts | 12 +- packages/client-runtime/src/state/models.ts | 8 +- .../client-runtime/src/state/threadDetail.ts | 4 +- .../client-runtime/src/state/threadState.ts | 10 +- .../src/state/threads-sync-v2.test.ts | 253 +++++++ .../src/state/threads-sync.test.ts | 6 +- packages/client-runtime/src/state/threads.ts | 625 +++++++++++++++--- .../src/state/windowedThread.test.ts | 103 +++ .../src/state/windowedThread.ts | 433 ++++++++++++ packages/contracts/src/environment.ts | 12 + packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestration.ts | 175 +++++ packages/contracts/src/rpc.ts | 21 + packages/contracts/src/rpcCompression.ts | 113 ++++ packages/contracts/src/threadSyncV2.test.ts | 67 ++ pnpm-lock.yaml | 22 + 55 files changed, 3930 insertions(+), 211 deletions(-) create mode 100644 apps/server/src/orchestration/Layers/ThreadSyncQuery.realdata.test.ts create mode 100644 apps/server/src/orchestration/Layers/ThreadSyncQuery.test.ts create mode 100644 apps/server/src/orchestration/Layers/ThreadSyncQuery.ts create mode 100644 apps/server/src/orchestration/Services/ThreadSyncQuery.ts create mode 100644 apps/server/src/orchestration/ThreadSyncWire.ts create mode 100644 packages/client-runtime/src/environment/transport.ts create mode 100644 packages/client-runtime/src/state/threads-sync-v2.test.ts create mode 100644 packages/client-runtime/src/state/windowedThread.test.ts create mode 100644 packages/client-runtime/src/state/windowedThread.ts create mode 100644 packages/contracts/src/rpcCompression.ts create mode 100644 packages/contracts/src/threadSyncV2.test.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 269410b2352..0c7e1f725f1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -93,6 +93,7 @@ "expo-updates": "~56.0.19", "expo-web-browser": "~56.0.5", "expo-widgets": "~56.0.19", + "pako": "^2.1.0", "punycode": "^2.3.1", "react": "19.2.3", "react-dom": "19.2.3", @@ -116,6 +117,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@pierre/trees": "1.0.0-beta.4", + "@types/pako": "^2.0.3", "@types/react": "~19.2.0", "babel-preset-expo": "~56.0.0", "tailwindcss": "^4.0.0", diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 04729ad21df..575c2ecdc87 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -8,6 +8,7 @@ import { OrchestrationThreadDetailSnapshot, ServerConfig, VcsListRefsResult, + WindowedOrchestrationThread, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -17,7 +18,10 @@ import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; -const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2; +// Bumped 2 -> 3: the stored snapshot is now either a complete legacy detail +// snapshot or a bounded thread-sync-v2 window. Version-2 records fail decode +// and are evicted by loadDecodedCache, so upgrades take one clean resync. +const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 3; const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; const VCS_REFS_CACHE_SCHEMA_VERSION = 1; @@ -30,7 +34,7 @@ const StoredThreadSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION), environmentId: Schema.String, threadId: Schema.String, - snapshot: OrchestrationThreadDetailSnapshot, + snapshot: Schema.Union([OrchestrationThreadDetailSnapshot, WindowedOrchestrationThread]), }); const StoredServerConfig = Schema.Struct({ schemaVersion: Schema.Literal(SERVER_CONFIG_CACHE_SCHEMA_VERSION), @@ -150,7 +154,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { }), ), saveThread: Effect.fn("MobileEnvironmentCache.saveThread")(function* (environmentId, snapshot) { - const threadId = snapshot.thread.id; + const threadId = "head" in snapshot ? snapshot.head.id : snapshot.thread.id; const payload = yield* encodeStoredThreadSnapshot({ schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, environmentId, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 31ad5660983..b2503e71184 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -72,6 +72,9 @@ export interface ThreadDetailScreenProps { readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; + readonly onLoadOlder: () => Promise; + readonly hasOlder: boolean; + readonly loadingOlder: boolean; readonly onOpenConnectionEditor: () => void; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftImages: () => Promise; @@ -414,6 +417,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} + onLoadOlder={props.onLoadOlder} + hasOlder={props.hasOlder} + loadingOlder={props.loadingOlder} skills={selectedProviderSkills} /> diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 0875e48c0c6..0c8cecab1cd 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -137,6 +137,9 @@ export interface ThreadFeedProps { readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; + readonly onLoadOlder: () => Promise; + readonly hasOlder: boolean; + readonly loadingOlder: boolean; readonly skills?: ReadonlyArray; } @@ -146,10 +149,41 @@ function MessageAttachmentImage(props: { readonly className: string; readonly onPressImage: (uri: string, headers?: Record) => void; }) { - const uri = useAssetUrl(props.environmentId, { - _tag: "attachment", - attachmentId: props.attachmentId, - }); + const [requested, setRequested] = useState(false); + const uri = useAssetUrl( + props.environmentId, + requested + ? { + _tag: "attachment", + attachmentId: props.attachmentId, + } + : null, + ); + + // A resolution that never arrives (offline, URL-signing failure) must not + // strand the row on a spinner: fall back to the tappable placeholder so the + // user can retry. + useEffect(() => { + if (!requested || uri !== null) return; + const timeout = setTimeout(() => setRequested(false), 10_000); + return () => clearTimeout(timeout); + }, [requested, uri]); + + if (!requested) { + return ( + setRequested(true)} + > + + + Tap to load media + + + ); + } if (uri === null) { return ( @@ -878,6 +912,7 @@ function renderFeedEntry( return ( (null); const handleScroll = useCallback( (event: NativeSyntheticEvent) => { // anchorTopInset, not topContentInset: under automatic insets the list // rests at contentOffset.y = -headerHeight (the inset lives only in // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the // header height back or the material toggles a full header too late. - reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); + const offset = event.nativeEvent.contentOffset.y; + reportHeaderMaterialVisibility(offset + anchorTopInset > 6); + // Thread-sync v2 pagination: nearing the top of the loaded window pulls + // the next page of older history (single-flight; loadingOlder gates it). + // Gated on UPWARD movement so a list shorter than the viewport — which + // rests inside the threshold — does not page eagerly on open or on + // unrelated scroll events. + const previousOffset = lastScrollOffsetRef.current; + lastScrollOffsetRef.current = offset; + const scrolledUpward = previousOffset !== null && offset < previousOffset; + if ( + scrolledUpward && + props.hasOlder && + !props.loadingOlder && + offset + anchorTopInset < 180 + ) { + void props.onLoadOlder(); + } }, - [reportHeaderMaterialVisibility, anchorTopInset], + [ + props.hasOlder, + props.loadingOlder, + props.onLoadOlder, + reportHeaderMaterialVisibility, + anchorTopInset, + ], ); const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); @@ -1577,7 +1639,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // own layout math) collapses the scroll view's adjustedContentInset // top to 0, leaving the iOS 26/27 scroll-edge effect no region to // render into — which is why the header blur was missing on threads. - scrollIndicatorInsets: { top: 0, left: 0, right: 0, bottom: 0 }, + scrollIndicatorInsets: { + top: 0, + left: 0, + right: 0, + bottom: 0, + }, } : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} @@ -1648,7 +1715,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onScroll={handleScroll} scrollEventThrottle={16} ListHeaderComponent={ - usesNativeAutomaticInsets ? null : + + {usesNativeAutomaticInsets ? null : } + {props.loadingOlder ? ( + + + + ) : null} + } contentContainerStyle={{ paddingTop: 12, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 5f5353c6eff..de2306d2e01 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -699,6 +699,9 @@ function ThreadRouteContent( draftAttachments={composer.draftAttachments} connectionStateLabel={routeConnectionState} threadSyncStatus={selectedThreadDetailState.status} + onLoadOlder={selectedThreadDetailState.loadOlder ?? (() => Promise.resolve())} + hasOlder={selectedThreadDetailState.hasOlder ?? false} + loadingOlder={selectedThreadDetailState.loadingOlder ?? false} activeThreadBusy={composer.activeThreadBusy} environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index fffadcebf1e..86c30cde2c5 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1,11 +1,58 @@ import * as Haptics from "expo-haptics"; import { SymbolView, type SFSymbol } from "expo-symbols"; -import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native"; +import { useEffect, useState } from "react"; +import type { ActivityPayloadAssetReference, EnvironmentId } from "@t3tools/contracts"; +import { + LayoutAnimation, + Linking, + Pressable, + ScrollView, + useColorScheme, + View, +} from "react-native"; import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; import Animated, { FadeIn } from "react-native-reanimated"; +import { useAssetUrl } from "../../state/assets"; + +function ActivityPayloadAssetLink(props: { + readonly environmentId: EnvironmentId; + readonly asset: ActivityPayloadAssetReference; +}) { + const [requested, setRequested] = useState(false); + const uri = useAssetUrl(props.environmentId, requested ? props.asset.resource : null); + useEffect(() => { + if (uri === null) return; + void Linking.openURL(uri) + .catch(() => {}) + .finally(() => setRequested(false)); + }, [uri]); + // A lookup that never resolves (offline, signing failure) must not leave the + // link stuck on "Loading media…" — reset so the user can retry. + useEffect(() => { + if (!requested || uri !== null) return; + const timeout = setTimeout(() => setRequested(false), 10_000); + return () => clearTimeout(timeout); + }, [requested, uri]); + + return ( + { + event.stopPropagation(); + setRequested(true); + }} + className="rounded-md px-1.5 py-1" + > + + {requested ? "Loading media…" : "Open media"} + + + ); +} const WORK_LOG_LAYOUT_ANIMATION = { duration: 180, @@ -78,6 +125,7 @@ function isFreshRow(createdAt: string): boolean { } export function ThreadWorkLog(props: { + readonly environmentId: EnvironmentId; readonly activities: ReadonlyArray; readonly copiedRowId: string | null; readonly expandedRows: Readonly>; @@ -89,7 +137,10 @@ export function ThreadWorkLog(props: { const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)"; const rows = props.activities .filter((activity) => !(activity.toolLike && activity.status === "neutral")) - .map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail) })); + .map((activity) => ({ + ...activity, + detail: compactActivityDetail(activity.detail), + })); if (rows.length === 0) { return null; @@ -165,6 +216,12 @@ export function ThreadWorkLog(props: { + {row.payloadAsset ? ( + + ) : null} {props.copiedRowId === row.id ? ( Copied diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index 98730edfbfc..77226ae3238 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -1,7 +1,9 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Socket from "effect/unstable/socket/Socket"; +import * as pako from "pako"; +import { type CompressionCodec, RpcCompressionCodec } from "@t3tools/contracts"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { cryptoLayer } from "../features/cloud/dpop"; @@ -16,17 +18,28 @@ function configuredRelayUrl(): string { const httpClientLayer = remoteHttpClientLayer(fetch); +// RN gzip codec for the /ws RPC (Fix 1). pako runs synchronously on the JS +// thread; fine for one-shot thread sync. +const pakoCodec: CompressionCodec = { + compressSync: (b) => pako.gzip(b), + decompressSync: (b) => pako.inflate(b), + threshold: 1024, +}; +const compressionCodecLayer = Layer.succeed(RpcCompressionCodec, pakoCodec); + type RuntimeLayerSource = | ReturnType | typeof Socket.layerWebSocketConstructorGlobal + | typeof compressionCodecLayer | typeof cryptoLayer | typeof httpClientLayer | typeof Persistence.layer | typeof tracingLayer; -const runtimeLayer = Layer.merge( +const runtimeLayer = Layer.mergeAll( managedRelayClientLayer(configuredRelayUrl()), Socket.layerWebSocketConstructorGlobal, + compressionCodecLayer, ).pipe( Layer.provideMerge(cryptoLayer), Layer.provideMerge(httpClientLayer), diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 23b47fc625f..cb42a41a544 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -106,6 +106,51 @@ describe("buildThreadFeed", () => { ]); }); + it("builds v2 chronology from the loaded window only", () => { + const thread = makeThread({ + id: ThreadId.make("thread-window"), + projectId: ProjectId.make("project-1"), + title: "Windowed thread", + messages: [ + { + id: MessageId.make("message-tail"), + role: "user", + text: "Latest loaded message", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:10.000Z", + updatedAt: "2026-04-01T00:00:10.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("pending-before-window"), + kind: "approval.requested", + summary: "Historic pending metadata", + createdAt: "2026-04-01T00:00:01.000Z", + }), + makeActivity({ + id: EventId.make("activity-in-window"), + kind: "runtime.warning", + summary: "Loaded activity", + createdAt: "2026-04-01T00:00:11.000Z", + }), + ], + }); + + const feed = buildThreadFeed({ ...thread, syncVersion: 2 }); + + // v2 paginates messages and activities INDEPENDENTLY: an activity older + // than the oldest loaded message is still part of the loaded window and + // must render, or paged-in activity-only history would be hidden forever. + // Only an explicit `loadedMessages` override implies a message cutoff. + expect(feed.map((entry) => entry.id)).toEqual([ + "pending-before-window", + "message-tail", + "activity-in-window", + ]); + }); + it("collapses matching tool lifecycle rows like desktop", () => { const thread = makeThread({ id: ThreadId.make("thread-2"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9f79a90550d..9fc5b924e9b 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,5 +1,6 @@ import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; import type { + ActivityPayloadAssetReference, OrchestrationLatestTurn, OrchestrationThread, OrchestrationThreadActivity, @@ -53,6 +54,7 @@ export interface ThreadFeedActivity { | "zap"; readonly toolLike: boolean; readonly status: "success" | "failure" | "neutral" | null; + readonly payloadAsset?: ActivityPayloadAssetReference; } const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; @@ -74,6 +76,7 @@ interface WorkLogEntry { requestKind?: PendingApproval["requestKind"]; toolLifecycleStatus?: WorkLogToolLifecycleStatus; toolData?: unknown; + payloadAsset?: ActivityPayloadAssetReference; } interface DerivedWorkLogEntry extends WorkLogEntry { @@ -291,6 +294,18 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo : activity.tone, activityKind: activity.kind, }; + const payloadData = asRecord(payload?.data); + if ( + payloadData?._tag === "activity-payload-asset" && + typeof payloadData.mediaType === "string" && + typeof payloadData.byteLength === "number" && + typeof payloadData.sha256 === "string" + ) { + const resource = asRecord(payloadData.resource); + if (resource?._tag === "attachment" && typeof resource.attachmentId === "string") { + entry.payloadAsset = payloadData as unknown as ActivityPayloadAssetReference; + } + } const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); if ( @@ -387,6 +402,7 @@ function mergeDerivedWorkLogEntries( const collapseKey = next.collapseKey ?? previous.collapseKey; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; const toolData = next.toolData ?? previous.toolData; + const payloadAsset = next.payloadAsset ?? previous.payloadAsset; return { ...previous, ...next, @@ -400,6 +416,7 @@ function mergeDerivedWorkLogEntries( ...(collapseKey ? { collapseKey } : {}), ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), ...(toolData !== undefined ? { toolData } : {}), + ...(payloadAsset !== undefined ? { payloadAsset } : {}), }; } @@ -1310,14 +1327,24 @@ export function buildPendingUserInputAnswers( } export function buildThreadFeed( - thread: OrchestrationThread, + thread: { + readonly syncVersion?: 2; + readonly messages: OrchestrationThread["messages"]; + readonly activities: OrchestrationThread["activities"]; + }, options?: { readonly loadedMessages?: ReadonlyArray; }, ): ThreadFeedEntry[] { const loadedMessages = options?.loadedMessages ?? thread.messages; - const oldestLoadedMessageCreatedAt = - options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; + // Only an explicitly provided message window implies a cutoff. A v2 thread + // paginates messages and activities INDEPENDENTLY, so its window may hold + // activities older than any loaded message; filtering those out would hide + // paged-in activity history permanently. + const isLoadedWindow = options?.loadedMessages !== undefined; + const oldestLoadedMessageCreatedAt = isLoadedWindow + ? (loadedMessages[0]?.createdAt ?? null) + : null; const workLogEntries = deriveWorkLogEntries(thread.activities); const entries = Arr.sortWith( [ @@ -1329,7 +1356,7 @@ export function buildThreadFeed( })), ...workLogEntries .filter((entry) => { - if (options?.loadedMessages === undefined) { + if (!isLoadedWindow) { return true; } return ( @@ -1360,6 +1387,7 @@ export function buildThreadFeed( .join("\n"), toolLike: workLogEntryIsToolLike(entry), status: workEntryStatus(entry), + ...(entry.payloadAsset === undefined ? {} : { payloadAsset: entry.payloadAsset }), }, }; }), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b5fbd8e1088..83d5fa621b7 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -136,6 +136,11 @@ export const make = Effect.gen(function* () { capabilities: { repositoryIdentity: true, }, + rpcTransports: [ + { kind: "gzip-json", path: "/ws-compressed" }, + { kind: "json", path: "/ws" }, + ], + threadSyncVersions: [1, 2], }; return ServerEnvironment.of({ diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 405103450e8..5a1766008b5 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -102,6 +102,7 @@ describe("OrchestrationEngine", () => { return savedEvent; }), readFromSequence: () => Stream.empty, + readThreadFromSequence: () => Stream.empty, readAll: () => Stream.fail( new PersistenceSqlError({ @@ -769,6 +770,7 @@ describe("OrchestrationEngine", () => { events.push(savedEvent); return Effect.succeed(savedEvent); }, + readThreadFromSequence: () => Stream.empty, readFromSequence(sequenceExclusive) { return Stream.fromIterable(events.filter((event) => event.sequence > sequenceExclusive)); }, @@ -1001,6 +1003,7 @@ describe("OrchestrationEngine", () => { events.push(savedEvent); return Effect.succeed(savedEvent); }, + readThreadFromSequence: () => Stream.empty, readFromSequence(sequenceExclusive) { return Stream.fromIterable(events.filter((event) => event.sequence > sequenceExclusive)); }, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 9598d1c3ef5..fe0e0f8447d 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -309,6 +309,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) => eventStore.readFromSequence(fromSequenceExclusive, limit); + const readThreadEvents: OrchestrationEngineShape["readThreadEvents"] = ( + threadId, + fromSequenceExclusive, + limit, + ) => eventStore.readThreadFromSequence(threadId, fromSequenceExclusive, limit); + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => Effect.gen(function* () { const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); @@ -322,6 +328,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { return { readEvents, + readThreadEvents, dispatch, // Each access creates a fresh PubSub subscription so that multiple // consumers (wsServer, ProviderRuntimeIngestion, CheckpointReactor, etc.) diff --git a/apps/server/src/orchestration/Layers/ThreadSyncQuery.realdata.test.ts b/apps/server/src/orchestration/Layers/ThreadSyncQuery.realdata.test.ts new file mode 100644 index 00000000000..f59013d65fb --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadSyncQuery.realdata.test.ts @@ -0,0 +1,180 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { OrchestrationThreadV2StreamItem, ThreadId, type ThreadHead } from "@t3tools/contracts"; +import { makeSqlitePersistenceLive } from "../../persistence/Layers/Sqlite.ts"; +import { ThreadSyncQuery } from "../Services/ThreadSyncQuery.ts"; +import { ThreadSyncQueryLive } from "./ThreadSyncQuery.ts"; +import { buildSnapshotChunks } from "../ThreadSyncWire.ts"; + +// Diagnostic: replay REAL thread data (the disposable sandbox DB copy) through +// the exact cold-sync head composition subscribeThreadV2 emits, then apply the +// same wire round-trip and staging validations the mobile client performs. +// Purpose: find which validation trips on real-world data (on-device symptom: +// every thread hangs on "Loading messages..." with a silent resync no-op). +// Opt-in: point this at a copy of a real state.sqlite to replay production-scale +// threads through the v2 pipeline. Skipped entirely when unset (CI-safe). +const SANDBOX_DB = process.env.T3_THREAD_SYNC_REALDATA_DB; + +const layer = it.layer( + ThreadSyncQueryLive.pipe( + Layer.provideMerge(makeSqlitePersistenceLive(SANDBOX_DB ?? ":memory:")), + Layer.provideMerge(NodeServices.layer), + ), +); + +layer("ThreadSyncQuery real data", (it) => { + const testIfConfigured = SANDBOX_DB === undefined ? it.effect.skip : it.effect; + testIfConfigured( + "cold-sync head for the busiest real threads survives wire round-trip and staging", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ThreadSyncQuery; + + // The busiest threads by activity count — the ones that hang on-device. + const busiest = yield* sql` + SELECT thread_id AS id, COUNT(*) AS n + FROM projection_thread_activities + GROUP BY thread_id ORDER BY n DESC LIMIT 5 + `; + assert.isAbove(busiest.length, 0); + + for (const row of busiest) { + const threadId = ThreadId.make(String(row.id)); + const tailOption = yield* query.getTail(threadId).pipe( + Effect.catch((error) => + Effect.sync(() => { + assert.fail(`getTail(${threadId}) failed: ${String(error)}`); + }), + ), + ); + if (Option.isNone(tailOption)) continue; // deleted thread + const snapshot = tailOption.value; + + // Compose EXACTLY like apps/server/src/ws.ts (externalization skipped: + // raw payloads only make chunks bigger, not structurally different). + const built = buildSnapshotChunks({ + snapshotId: "diag-snapshot", + head: snapshot.head as ThreadHead, + messages: snapshot.messages, + activities: snapshot.activities, + }); + const items = [ + { + kind: "snapshot-start" as const, + snapshotId: "diag-snapshot", + historyEpoch: snapshot.historyEpoch, + watermark: snapshot.watermark, + chunkCount: built.chunks.length, + inlineBytes: built.inlineBytes, + }, + ...built.chunks, + { + kind: "snapshot-complete" as const, + snapshotId: "diag-snapshot", + historyEpoch: snapshot.historyEpoch, + lastAppliedSequence: snapshot.watermark, + before: { + message: + built.messages[0] === undefined + ? null + : { createdAt: built.messages[0].createdAt, messageId: built.messages[0].id }, + activity: + built.activities[0] === undefined + ? null + : { + createdAt: built.activities[0].createdAt, + activityId: built.activities[0].id, + }, + }, + hasOlderMessages: snapshot.head.counts.messages > built.messages.length, + hasOlderActivities: snapshot.head.counts.activities > built.activities.length, + }, + ]; + + // Wire round-trip every item; report the exact item kind that fails. + const decoded = items.map((item, index) => { + try { + const encoded = Schema.encodeUnknownSync(OrchestrationThreadV2StreamItem)(item); + const json = JSON.parse(JSON.stringify(encoded)) as unknown; + return Schema.decodeUnknownSync(OrchestrationThreadV2StreamItem)(json); + } catch (error) { + assert.fail( + `thread ${threadId}: item[${index}] kind=${item.kind} failed wire round-trip:\n${String(error).slice(0, 1500)}`, + ); + } + }); + + // Replay the client staging validations verbatim (threads.ts applyV2Item). + interface StagingState { + snapshotId: string; + historyEpoch: number; + watermark: number; + chunkCount: number; + nextIndex: number; + head: unknown | null; + } + let staging: StagingState | null = null; + let committed = false; + for (const item of decoded) { + if (item === undefined) continue; + if (item.kind === "snapshot-start") { + staging = { + snapshotId: item.snapshotId, + historyEpoch: item.historyEpoch, + watermark: item.watermark, + chunkCount: item.chunkCount, + nextIndex: 0, + head: null, + }; + } else if (item.kind === "snapshot-chunk") { + const current: StagingState | null = staging; + if (current === null) { + throw new Error(`thread ${threadId}: chunk before snapshot-start`); + } + if (current.nextIndex !== item.index) { + throw new Error( + `thread ${threadId}: chunk index mismatch (expected ${current.nextIndex}, got ${item.index})`, + ); + } + if (item.head !== undefined) { + if (current.head !== null) { + throw new Error(`thread ${threadId}: duplicate head chunk`); + } + current.head = item.head; + } + current.nextIndex += 1; + } else if (item.kind === "snapshot-complete") { + const current: StagingState | null = staging; + if (current === null) { + throw new Error(`thread ${threadId}: complete without start`); + } + if (current.nextIndex !== current.chunkCount) { + throw new Error( + `thread ${threadId}: chunkCount mismatch (advertised ${current.chunkCount}, received ${current.nextIndex})`, + ); + } + if (current.head === null) { + throw new Error(`thread ${threadId}: no head chunk received`); + } + if (current.historyEpoch !== item.historyEpoch) { + throw new Error(`thread ${threadId}: historyEpoch mismatch`); + } + if (current.watermark !== item.lastAppliedSequence) { + throw new Error(`thread ${threadId}: watermark mismatch`); + } + committed = true; + } + } + assert.isTrue(committed, `thread ${threadId}: snapshot never completed staging`); + } + }), + { timeout: 120_000 }, + ); +}); diff --git a/apps/server/src/orchestration/Layers/ThreadSyncQuery.test.ts b/apps/server/src/orchestration/Layers/ThreadSyncQuery.test.ts new file mode 100644 index 00000000000..8e978618b95 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadSyncQuery.test.ts @@ -0,0 +1,106 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ThreadId } from "@t3tools/contracts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ThreadSyncQuery } from "../Services/ThreadSyncQuery.ts"; +import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; +import { ThreadSyncQueryLive } from "./ThreadSyncQuery.ts"; + +const layer = it.layer(ThreadSyncQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("ThreadSyncQuery", (it) => { + it.effect("returns a bounded tail and keyset pages invalidated only by revert epoch", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ThreadSyncQuery; + const threadId = ThreadId.make("thread-sync-query"); + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + branch, worktree_path, latest_turn_id, created_at, updated_at, archived_at, + latest_user_message_at, pending_approval_count, pending_user_input_count, + has_actionable_proposed_plan, deleted_at + ) VALUES ( + ${threadId}, 'project-1', 'Thread', '{"instanceId":"codex","model":"gpt-5.4"}', + 'full-access', 'default', 'main', NULL, NULL, '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z', NULL, NULL, 0, 0, 0, NULL + ) + `; + for (const projector of [ + ORCHESTRATION_PROJECTOR_NAMES.threads, + ORCHESTRATION_PROJECTOR_NAMES.threadMessages, + ORCHESTRATION_PROJECTOR_NAMES.threadActivities, + ORCHESTRATION_PROJECTOR_NAMES.threadSessions, + ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans, + ORCHESTRATION_PROJECTOR_NAMES.threadTurns, + ORCHESTRATION_PROJECTOR_NAMES.pendingApprovals, + ]) { + yield* sql`INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, 77, '2026-01-01T00:00:00.000Z')`; + } + for (let index = 0; index < 40; index += 1) { + const at = `2026-01-01T00:${Math.floor(index / 60) + .toString() + .padStart(2, "0")}:${(index % 60).toString().padStart(2, "0")}.000Z`; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) VALUES (${`message-${index.toString().padStart(3, "0")}`}, ${threadId}, NULL, + 'user', ${`message ${index}`}, NULL, 0, ${at}, ${at}) + `; + } + for (let index = 0; index < 140; index += 1) { + const at = `2026-01-02T00:${Math.floor(index / 60) + .toString() + .padStart(2, "0")}:${(index % 60).toString().padStart(2, "0")}.000Z`; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) VALUES (${`activity-${index.toString().padStart(3, "0")}`}, ${threadId}, NULL, + 'info', 'task.progress', ${`activity ${index}`}, '{}', ${index + 1}, ${at}) + `; + } + + const tailOption = yield* query.getTail(threadId); + const tail = Option.getOrThrow(tailOption); + assert.strictEqual(tail.messages.length, 32); + assert.strictEqual(tail.activities.length, 128); + assert.strictEqual(tail.messages[0]?.id, "message-008"); + assert.strictEqual(tail.watermark, 77); + + const firstPage = yield* query.getHistoryPage({ + threadId, + historyEpoch: tail.historyEpoch, + before: tail.before, + messageLimit: 32, + activityLimit: 128, + }); + if (firstPage.page === null) return yield* Effect.die("Expected a history page."); + assert.strictEqual(firstPage.page.messages.length, 8); + assert.strictEqual(firstPage.page.activities.length, 12); + assert.strictEqual(firstPage.page.hasOlderMessages, false); + + yield* sql` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, + command_id, causation_event_id, correlation_id, actor_kind, payload_json, metadata_json + ) VALUES ('revert-event', 'thread', ${threadId}, 1, 'thread.reverted', + '2026-01-03T00:00:00.000Z', NULL, NULL, NULL, 'system', '{}', '{}') + `; + const invalidated = yield* query.getHistoryPage({ + threadId, + historyEpoch: tail.historyEpoch, + before: tail.before, + messageLimit: 32, + activityLimit: 128, + }); + assert.strictEqual(invalidated.page, null); + assert(invalidated.currentHistoryEpoch > tail.historyEpoch); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ThreadSyncQuery.ts b/apps/server/src/orchestration/Layers/ThreadSyncQuery.ts new file mode 100644 index 00000000000..61a10b531b4 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadSyncQuery.ts @@ -0,0 +1,478 @@ +import { + ChatAttachment, + IsoDateTime, + MessageId, + ModelSelection, + NonNegativeInt, + OrchestrationProposedPlanId, + OrchestrationThreadActivity, + ThreadId, + TurnId, + type OrchestrationGetThreadHistoryPageResult, + type OrchestrationLatestTurn, + type OrchestrationProposedPlan, + type OrchestrationSession, + type ThreadHistoryCursor, + type ThreadWindowMessage, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type ProjectionRepositoryError, +} from "../../persistence/Errors.ts"; +import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; +import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; +import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; +import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import { + THREAD_SYNC_TAIL_ACTIVITY_LIMIT, + THREAD_SYNC_TAIL_MESSAGE_LIMIT, + ThreadSyncQuery, + type ThreadSyncQueryShape, +} from "../Services/ThreadSyncQuery.ts"; +import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; + +const ThreadLookup = Schema.Struct({ threadId: ThreadId }); +const PageLookup = Schema.Struct({ + threadId: ThreadId, + beforeCreatedAt: Schema.NullOr(IsoDateTime), + beforeId: Schema.NullOr(Schema.String), + limit: NonNegativeInt, +}); +const ProjectionThreadDbRow = ProjectionThread.mapFields( + Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection) }), +); +const ProjectionMessageDbRow = ProjectionThreadMessage.mapFields( + Struct.assign({ + attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + isStreaming: Schema.Number, + }), +); +const ProjectionActivityDbRow = ProjectionThreadActivity.mapFields( + Struct.assign({ + payload: Schema.fromJsonString(Schema.Unknown), + sequence: Schema.NullOr(NonNegativeInt), + }), +); +const ProjectionLatestTurnRow = Schema.Struct({ + turnId: TurnId, + state: Schema.String, + requestedAt: IsoDateTime, + startedAt: Schema.NullOr(IsoDateTime), + completedAt: Schema.NullOr(IsoDateTime), + assistantMessageId: Schema.NullOr(MessageId), + sourceProposedPlanThreadId: Schema.NullOr(ThreadId), + sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), +}); +const CountsRow = Schema.Struct({ + messages: NonNegativeInt, + activities: NonNegativeInt, +}); +const SequenceRow = Schema.Struct({ value: NonNegativeInt }); +const ProjectionSessionRow = ProjectionThreadSession; +const ProjectionPlanRow = ProjectionThreadProposedPlan; + +function mapError(operation: string) { + return (cause: unknown): ProjectionRepositoryError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(`${operation}:decode`)(cause) + : toPersistenceSqlError(`${operation}:query`)(cause); +} + +function mapMessage(row: typeof ProjectionMessageDbRow.Type): ThreadWindowMessage { + return { + id: row.messageId, + role: row.role, + text: row.text, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function mapActivity(row: typeof ProjectionActivityDbRow.Type): OrchestrationThreadActivity { + return { + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + }; +} + +function mapLatestTurn(row: typeof ProjectionLatestTurnRow.Type): OrchestrationLatestTurn { + return { + turnId: row.turnId, + state: + row.state === "error" + ? "error" + : row.state === "interrupted" + ? "interrupted" + : row.state === "completed" + ? "completed" + : "running", + requestedAt: row.requestedAt, + startedAt: row.startedAt, + completedAt: row.completedAt, + assistantMessageId: row.assistantMessageId, + ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null + ? { + sourceProposedPlan: { + threadId: row.sourceProposedPlanThreadId, + planId: row.sourceProposedPlanId, + }, + } + : {}), + }; +} + +function mapSession(row: typeof ProjectionSessionRow.Type): OrchestrationSession { + return { + threadId: row.threadId, + status: row.status, + providerName: row.providerName, + ...(row.providerInstanceId !== null ? { providerInstanceId: row.providerInstanceId } : {}), + runtimeMode: row.runtimeMode, + activeTurnId: row.activeTurnId, + lastError: row.lastError, + updatedAt: row.updatedAt, + }; +} + +function mapPlan(row: typeof ProjectionPlanRow.Type): OrchestrationProposedPlan { + return { + id: row.planId, + turnId: row.turnId, + planMarkdown: row.planMarkdown, + implementedAt: row.implementedAt, + implementationThreadId: row.implementationThreadId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function cursors( + messages: ReadonlyArray, + activities: ReadonlyArray, + fallback: ThreadHistoryCursor = { message: null, activity: null }, +): ThreadHistoryCursor { + const message = messages[0]; + const activity = activities[0]; + return { + message: + message === undefined + ? fallback.message + : { createdAt: message.createdAt, messageId: message.id }, + activity: + activity === undefined + ? fallback.activity + : { createdAt: activity.createdAt, activityId: activity.id }, + }; +} + +const makeThreadSyncQuery = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const getThread = SqlSchema.findOneOption({ + Request: ThreadLookup, + Result: ProjectionThreadDbRow, + execute: ({ threadId }) => sql` + SELECT thread_id AS "threadId", project_id AS "projectId", title, + model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", + archived_at AS "archivedAt", latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" + FROM projection_threads + WHERE thread_id = ${threadId} AND deleted_at IS NULL + LIMIT 1 + `, + }); + const getLatestTurn = SqlSchema.findOneOption({ + Request: ThreadLookup, + Result: ProjectionLatestTurnRow, + execute: ({ threadId }) => sql` + SELECT turns.turn_id AS "turnId", turns.state, turns.requested_at AS "requestedAt", + turns.started_at AS "startedAt", turns.completed_at AS "completedAt", + turns.assistant_message_id AS "assistantMessageId", + turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + turns.source_proposed_plan_id AS "sourceProposedPlanId" + FROM projection_threads threads + JOIN projection_turns turns ON turns.thread_id = threads.thread_id + AND turns.turn_id = threads.latest_turn_id + WHERE threads.thread_id = ${threadId} + LIMIT 1 + `, + }); + const getSession = SqlSchema.findOneOption({ + Request: ThreadLookup, + Result: ProjectionSessionRow, + execute: ({ threadId }) => sql` + SELECT thread_id AS "threadId", status, provider_name AS "providerName", + provider_instance_id AS "providerInstanceId", runtime_mode AS "runtimeMode", + active_turn_id AS "activeTurnId", last_error AS "lastError", updated_at AS "updatedAt" + FROM projection_thread_sessions WHERE thread_id = ${threadId} LIMIT 1 + `, + }); + const getActivePlan = SqlSchema.findOneOption({ + Request: ThreadLookup, + Result: ProjectionPlanRow, + execute: ({ threadId }) => sql` + SELECT plan_id AS "planId", thread_id AS "threadId", turn_id AS "turnId", + plan_markdown AS "planMarkdown", implemented_at AS "implementedAt", + implementation_thread_id AS "implementationThreadId", created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} AND implemented_at IS NULL + ORDER BY updated_at DESC, plan_id DESC LIMIT 1 + `, + }); + const getCounts = SqlSchema.findOne({ + Request: ThreadLookup, + Result: CountsRow, + execute: ({ threadId }) => sql` + SELECT + (SELECT COUNT(*) FROM projection_thread_messages WHERE thread_id = ${threadId}) AS messages, + (SELECT COUNT(*) FROM projection_thread_activities WHERE thread_id = ${threadId}) AS activities + `, + }); + const getWatermark = SqlSchema.findOne({ + Request: Schema.Void, + Result: SequenceRow, + execute: () => sql` + SELECT MIN( + COALESCE((SELECT last_applied_sequence FROM projection_state + WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.threads}), 0), + COALESCE((SELECT last_applied_sequence FROM projection_state + WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}), 0), + COALESCE((SELECT last_applied_sequence FROM projection_state + WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}), 0), + COALESCE((SELECT last_applied_sequence FROM projection_state + WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}), 0), + COALESCE((SELECT last_applied_sequence FROM projection_state + WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}), 0), + COALESCE((SELECT last_applied_sequence FROM projection_state + WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.threadTurns}), 0), + COALESCE((SELECT last_applied_sequence FROM projection_state + WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.pendingApprovals}), 0) + ) AS value + `, + }); + const getHistoryEpoch = SqlSchema.findOne({ + Request: ThreadLookup, + Result: SequenceRow, + execute: ({ threadId }) => sql` + SELECT COALESCE(MAX(sequence), 0) AS value + FROM orchestration_events + WHERE aggregate_kind = 'thread' AND stream_id = ${threadId} AND event_type = 'thread.reverted' + `, + }); + const listTailMessages = SqlSchema.findAll({ + Request: ThreadLookup, + Result: ProjectionMessageDbRow, + execute: ({ threadId }) => sql` + SELECT message_id AS "messageId", thread_id AS "threadId", turn_id AS "turnId", role, text, + attachments_json AS attachments, is_streaming AS "isStreaming", + created_at AS "createdAt", updated_at AS "updatedAt" + FROM projection_thread_messages WHERE thread_id = ${threadId} + ORDER BY created_at DESC, message_id DESC LIMIT ${THREAD_SYNC_TAIL_MESSAGE_LIMIT} + `, + }); + const listTailActivities = SqlSchema.findAll({ + Request: ThreadLookup, + Result: ProjectionActivityDbRow, + execute: ({ threadId }) => sql` + SELECT activity_id AS "activityId", thread_id AS "threadId", turn_id AS "turnId", tone, + kind, summary, payload_json AS payload, sequence, created_at AS "createdAt" + FROM projection_thread_activities WHERE thread_id = ${threadId} + ORDER BY created_at DESC, activity_id DESC LIMIT ${THREAD_SYNC_TAIL_ACTIVITY_LIMIT} + `, + }); + const listPendingRequests = SqlSchema.findAll({ + Request: ThreadLookup, + Result: ProjectionActivityDbRow, + execute: ({ threadId }) => sql` + WITH latest_user_input AS ( + SELECT activity_id, kind FROM ( + SELECT activity_id, kind, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY created_at DESC, activity_id DESC + ) AS row_number + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND json_extract(payload_json, '$.requestId') IS NOT NULL + AND kind IN ('user-input.requested', 'user-input.resolved', 'provider.user-input.respond.failed') + ) WHERE row_number = 1 + ) + SELECT activity.activity_id AS "activityId", activity.thread_id AS "threadId", + activity.turn_id AS "turnId", activity.tone, activity.kind, activity.summary, + activity.payload_json AS payload, activity.sequence, activity.created_at AS "createdAt" + FROM projection_thread_activities activity + WHERE activity.thread_id = ${threadId} + AND ( + (activity.kind = 'approval.requested' AND EXISTS ( + SELECT 1 FROM projection_pending_approvals pending + WHERE pending.thread_id = activity.thread_id AND pending.status = 'pending' + AND pending.request_id = json_extract(activity.payload_json, '$.requestId') + )) + OR activity.activity_id IN ( + SELECT activity_id FROM latest_user_input WHERE kind = 'user-input.requested' + ) + ) + ORDER BY activity.created_at ASC, activity.activity_id ASC + `, + }); + const listMessagePage = SqlSchema.findAll({ + Request: PageLookup, + Result: ProjectionMessageDbRow, + execute: ({ threadId, beforeCreatedAt, beforeId, limit }) => sql` + SELECT message_id AS "messageId", thread_id AS "threadId", turn_id AS "turnId", role, text, + attachments_json AS attachments, is_streaming AS "isStreaming", + created_at AS "createdAt", updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} AND ${beforeCreatedAt} IS NOT NULL AND ( + created_at < ${beforeCreatedAt} + OR (created_at = ${beforeCreatedAt} AND message_id < ${beforeId}) + ) + ORDER BY created_at DESC, message_id DESC LIMIT ${limit} + `, + }); + const listActivityPage = SqlSchema.findAll({ + Request: PageLookup, + Result: ProjectionActivityDbRow, + execute: ({ threadId, beforeCreatedAt, beforeId, limit }) => sql` + SELECT activity_id AS "activityId", thread_id AS "threadId", turn_id AS "turnId", tone, + kind, summary, payload_json AS payload, sequence, created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} AND ${beforeCreatedAt} IS NOT NULL AND ( + created_at < ${beforeCreatedAt} + OR (created_at = ${beforeCreatedAt} AND activity_id < ${beforeId}) + ) + ORDER BY created_at DESC, activity_id DESC LIMIT ${limit} + `, + }); + + const getTail: ThreadSyncQueryShape["getTail"] = (threadId) => + sql + .withTransaction( + Effect.gen(function* () { + const [ + thread, + latestTurn, + session, + activePlan, + counts, + watermark, + epoch, + messageRows, + activityRows, + pendingRows, + ] = yield* Effect.all([ + getThread({ threadId }), + getLatestTurn({ threadId }), + getSession({ threadId }), + getActivePlan({ threadId }), + getCounts({ threadId }), + getWatermark(undefined), + getHistoryEpoch({ threadId }), + listTailMessages({ threadId }), + listTailActivities({ threadId }), + listPendingRequests({ threadId }), + ]); + if (Option.isNone(thread)) return Option.none(); + const messages = messageRows.toReversed().map(mapMessage); + const tailActivities = activityRows.toReversed().map(mapActivity); + return Option.some({ + watermark: watermark.value, + historyEpoch: epoch.value, + head: { + id: thread.value.threadId, + projectId: thread.value.projectId, + title: thread.value.title, + modelSelection: thread.value.modelSelection, + runtimeMode: thread.value.runtimeMode, + interactionMode: thread.value.interactionMode, + branch: thread.value.branch, + worktreePath: thread.value.worktreePath, + latestTurn: Option.isSome(latestTurn) ? mapLatestTurn(latestTurn.value) : null, + createdAt: thread.value.createdAt, + updatedAt: thread.value.updatedAt, + archivedAt: thread.value.archivedAt, + deletedAt: thread.value.deletedAt, + session: Option.isSome(session) ? mapSession(session.value) : null, + activeProposedPlan: Option.isSome(activePlan) ? mapPlan(activePlan.value) : null, + pendingRequests: pendingRows.map(mapActivity), + counts, + }, + messages, + activities: tailActivities, + before: cursors(messages, tailActivities), + hasOlderMessages: counts.messages > messages.length, + hasOlderActivities: counts.activities > tailActivities.length, + }); + }), + ) + .pipe(Effect.mapError(mapError("ThreadSyncQuery.getTail"))); + + const getHistoryPage: ThreadSyncQueryShape["getHistoryPage"] = (input) => + sql + .withTransaction( + Effect.gen(function* () { + const epoch = yield* getHistoryEpoch({ threadId: input.threadId }); + if (epoch.value !== input.historyEpoch) { + return { currentHistoryEpoch: epoch.value, page: null }; + } + const messageLimit = Math.min(Math.max(input.messageLimit, 1), 64); + const activityLimit = Math.min(Math.max(input.activityLimit, 1), 256); + const [messageRows, activityRows] = yield* Effect.all([ + listMessagePage({ + threadId: input.threadId, + beforeCreatedAt: input.before.message?.createdAt ?? null, + beforeId: input.before.message?.messageId ?? null, + limit: messageLimit + 1, + }), + listActivityPage({ + threadId: input.threadId, + beforeCreatedAt: input.before.activity?.createdAt ?? null, + beforeId: input.before.activity?.activityId ?? null, + limit: activityLimit + 1, + }), + ]); + const hasOlderMessages = messageRows.length > messageLimit; + const hasOlderActivities = activityRows.length > activityLimit; + const messages = messageRows.slice(0, messageLimit).toReversed().map(mapMessage); + const activities = activityRows.slice(0, activityLimit).toReversed().map(mapActivity); + const page: OrchestrationGetThreadHistoryPageResult = { + historyEpoch: epoch.value, + messages, + activities, + before: cursors(messages, activities, input.before), + hasOlderMessages, + hasOlderActivities, + }; + return { currentHistoryEpoch: epoch.value, page }; + }), + ) + .pipe(Effect.mapError(mapError("ThreadSyncQuery.getHistoryPage"))); + + return ThreadSyncQuery.of({ getTail, getHistoryPage }); +}); + +export const ThreadSyncQueryLive = Layer.effect(ThreadSyncQuery, makeThreadSyncQuery); diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index dc1b5fd3003..fd217df4bc7 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -37,6 +37,21 @@ export interface OrchestrationEngineShape { limit?: number, ) => Stream.Stream; + /** + * Replay persisted events for a single thread aggregate from an exclusive + * sequence cursor. Backs incremental thread catch-up (Fix 2). + * + * @param threadId - Thread aggregate (stream) id. + * @param fromSequenceExclusive - Sequence cursor (exclusive). + * @param limit - Maximum number of events to emit. + * @returns Stream containing ordered events for the thread. + */ + readonly readThreadEvents: ( + threadId: string, + fromSequenceExclusive: number, + limit?: number, + ) => Stream.Stream; + /** * Dispatch a validated orchestration command. * diff --git a/apps/server/src/orchestration/Services/ThreadSyncQuery.ts b/apps/server/src/orchestration/Services/ThreadSyncQuery.ts new file mode 100644 index 00000000000..73ff2a2f4cc --- /dev/null +++ b/apps/server/src/orchestration/Services/ThreadSyncQuery.ts @@ -0,0 +1,46 @@ +import type { + OrchestrationGetThreadHistoryPageInput, + OrchestrationGetThreadHistoryPageResult, + OrchestrationThreadActivity, + ThreadHead, + ThreadHistoryCursor, + ThreadId, + ThreadWindowMessage, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; + +import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; + +export const THREAD_SYNC_TAIL_MESSAGE_LIMIT = 32; +export const THREAD_SYNC_TAIL_ACTIVITY_LIMIT = 128; + +export interface ThreadSyncTail { + readonly watermark: number; + readonly historyEpoch: number; + readonly head: ThreadHead; + readonly messages: ReadonlyArray; + readonly activities: ReadonlyArray; + readonly before: ThreadHistoryCursor; + readonly hasOlderMessages: boolean; + readonly hasOlderActivities: boolean; +} + +export interface ThreadHistoryPageQueryResult { + readonly currentHistoryEpoch: number; + readonly page: OrchestrationGetThreadHistoryPageResult | null; +} + +export interface ThreadSyncQueryShape { + readonly getTail: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly getHistoryPage: ( + input: OrchestrationGetThreadHistoryPageInput, + ) => Effect.Effect; +} + +export class ThreadSyncQuery extends Context.Service()( + "t3/orchestration/Services/ThreadSyncQuery", +) {} diff --git a/apps/server/src/orchestration/ThreadSyncWire.ts b/apps/server/src/orchestration/ThreadSyncWire.ts new file mode 100644 index 00000000000..e0b5d83d06e --- /dev/null +++ b/apps/server/src/orchestration/ThreadSyncWire.ts @@ -0,0 +1,348 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + +import type { + ActivityPayloadAssetReference, + OrchestrationEvent, + OrchestrationThreadActivity, + OrchestrationThreadV2StreamItem, + ThreadHead, + ThreadWindowMessage, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; +import * as ServerConfig from "../config.ts"; +import { inferImageExtension, parseBase64DataUrl } from "../imageMime.ts"; + +export const THREAD_SYNC_INLINE_BUDGET_BYTES = 512 * 1024; +export const THREAD_SYNC_CHUNK_MAX_BYTES = 256 * 1024; +const THREAD_SYNC_ACTIVITY_DATA_INLINE_BYTES = 32 * 1024; +const THREAD_SYNC_ACTIVITY_WIRE_MAX_BYTES = 128 * 1024; +const THREAD_SYNC_ACTIVITY_SUMMARY_MAX_BYTES = 16 * 1024; +const THREAD_SYNC_MESSAGE_TEXT_MAX_BYTES = 96 * 1024; +const THREAD_SYNC_CHUNK_TARGET_BYTES = THREAD_SYNC_CHUNK_MAX_BYTES - 4096; + +const wireBytes = (value: unknown): number => Buffer.byteLength(JSON.stringify(value), "utf8"); +const encodeUnknownJson = Schema.encodeSync(Schema.UnknownFromJsonString); + +function truncateUtf8(value: string, maxBytes: number, suffix: string): string { + if (Buffer.byteLength(value, "utf8") <= maxBytes) return value; + const contentBudget = Math.max(0, maxBytes - Buffer.byteLength(suffix, "utf8")); + let end = Math.min(value.length, contentBudget); + while (end > 0 && Buffer.byteLength(value.slice(0, end), "utf8") > contentBudget) { + end = Math.floor(end * 0.9); + } + return `${value.slice(0, end)}${suffix}`; +} + +function truncateUtf8Tail(value: string, maxBytes: number, prefix: string): string { + if (Buffer.byteLength(value, "utf8") <= maxBytes) return value; + const contentBudget = Math.max(0, maxBytes - Buffer.byteLength(prefix, "utf8")); + // Never let the slice begin on a low surrogate: splitting an astral pair + // turns the head into a replacement character and inflates its byte count. + const alignStart = (index: number): number => + index < value.length && (value.charCodeAt(index) & 0xfc00) === 0xdc00 ? index + 1 : index; + let start = alignStart(Math.max(0, value.length - contentBudget)); + while (start < value.length && Buffer.byteLength(value.slice(start), "utf8") > contentBudget) { + start = alignStart(start + Math.max(1, Math.ceil((value.length - start) * 0.1))); + } + return `${prefix}${value.slice(start)}`; +} + +function payloadRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function externalizableData(payload: Record): { + readonly bytes: Uint8Array; + readonly mediaType: string; + readonly extension: string; +} | null { + const data = payload.data; + if (typeof data === "string") { + const parsed = parseBase64DataUrl(data); + if (parsed !== null) { + return { + bytes: Buffer.from(parsed.base64, "base64"), + mediaType: parsed.mimeType, + extension: inferImageExtension({ mimeType: parsed.mimeType }), + }; + } + if (data.length >= THREAD_SYNC_ACTIVITY_DATA_INLINE_BYTES) { + // Only decode as base64 when the payload SAYS it is binary (a mimeType + // sibling) and the content round-trips: large plain text can match a + // base64 character sweep and would be silently corrupted by decoding. + const compact = data.replace(/\s+/gu, ""); + const declaredMediaType = typeof payload.mimeType === "string" ? payload.mimeType : null; + const decoded = + declaredMediaType !== null && + // A declared text payload is text, no matter how base64-ish it looks + // ("test ".repeat(n) whitespace-compacts into round-tripping base64). + !declaredMediaType.startsWith("text/") && + compact.length % 4 === 0 && + /^[A-Za-z0-9+/]+={0,2}$/u.test(compact) + ? Buffer.from(compact, "base64") + : null; + if ( + decoded !== null && + declaredMediaType !== null && + decoded.toString("base64") === compact + ) { + return { + bytes: decoded, + mediaType: declaredMediaType, + extension: declaredMediaType.startsWith("image/") + ? inferImageExtension({ mimeType: declaredMediaType }) + : ".bin", + }; + } + // Oversized plain text: externalize the UTF-8 bytes verbatim — bounded + // on the wire, impossible to corrupt. + return { + bytes: Buffer.from(data, "utf8"), + mediaType: "text/plain; charset=utf-8", + extension: ".txt", + }; + } + } + + if (data === undefined) return null; + const encoded = JSON.stringify(data); + if (Buffer.byteLength(encoded, "utf8") < THREAD_SYNC_ACTIVITY_DATA_INLINE_BYTES) return null; + return { + bytes: Buffer.from(encoded, "utf8"), + mediaType: "application/json", + extension: ".bin", + }; +} + +const storeActivityAsset = Effect.fn("ThreadSyncWire.storeActivityAsset")(function* (input: { + readonly bytes: Uint8Array; + readonly mediaType: string; + readonly extension: string; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const sha256 = NodeCrypto.createHash("sha256").update(input.bytes).digest("hex"); + const attachmentId = `activity-${sha256}`; + const assetPath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath: `${attachmentId}${input.extension}`, + }); + if (assetPath === null) return null; + if (!(yield* fileSystem.exists(assetPath))) { + yield* fileSystem.makeDirectory(path.dirname(assetPath), { recursive: true }); + yield* fileSystem.writeFile(assetPath, input.bytes); + } + + return { + _tag: "activity-payload-asset", + resource: { _tag: "attachment", attachmentId }, + mediaType: input.mediaType, + byteLength: input.bytes.byteLength, + sha256, + } satisfies ActivityPayloadAssetReference; +}); + +export const externalizeActivityPayload = Effect.fn("ThreadSyncWire.externalizeActivityPayload")( + function* (activity: OrchestrationThreadActivity) { + const payload = payloadRecord(activity.payload); + const summary = truncateUtf8( + activity.summary, + THREAD_SYNC_ACTIVITY_SUMMARY_MAX_BYTES, + "\n\n[Summary omitted from the synced window.]", + ); + if (payload === null) return { ...activity, summary }; + + const external = externalizableData(payload); + let next: OrchestrationThreadActivity = { ...activity, summary }; + if (external !== null && external.bytes.byteLength > 0) { + const reference = yield* storeActivityAsset(external); + if (reference !== null) next = { ...next, payload: { ...payload, data: reference } }; + } + if (wireBytes(next) <= THREAD_SYNC_ACTIVITY_WIRE_MAX_BYTES) return next; + + const reference = yield* storeActivityAsset({ + bytes: Buffer.from(encodeUnknownJson(payload), "utf8"), + mediaType: "application/json", + extension: ".bin", + }); + return reference === null ? next : { ...next, payload: { data: reference } }; + }, +); + +export const externalizeActivities = Effect.fn("ThreadSyncWire.externalizeActivities")(function* ( + activities: ReadonlyArray, +) { + return yield* Effect.forEach(activities, externalizeActivityPayload, { + concurrency: 4, + }); +}); + +export const externalizeThreadEvent = Effect.fn("ThreadSyncWire.externalizeThreadEvent")(function* ( + event: OrchestrationEvent, +) { + if (event.type !== "thread.activity-appended") return event; + return { + ...event, + payload: { + ...event.payload, + activity: yield* externalizeActivityPayload(event.payload.activity), + }, + } satisfies OrchestrationEvent; +}); + +export function trimWindowMessage(message: ThreadWindowMessage): ThreadWindowMessage { + const originalTextBytes = Buffer.byteLength(message.text, "utf8"); + if (originalTextBytes <= THREAD_SYNC_MESSAGE_TEXT_MAX_BYTES) return message; + return { + ...message, + // A STREAMING message keeps its TAIL (marker leads): the client appends + // subsequent deltas to the end, so head-truncation would strand the marker + // mid-text — and completion events carry empty text, which cannot repair + // it. Completed messages keep the head as before. + text: message.streaming + ? truncateUtf8Tail( + message.text, + THREAD_SYNC_MESSAGE_TEXT_MAX_BYTES, + "[Older content omitted from the synced window.]\n\n", + ) + : truncateUtf8( + message.text, + THREAD_SYNC_MESSAGE_TEXT_MAX_BYTES, + "\n\n[Older content omitted from the synced window.]", + ), + textTruncated: true, + originalTextBytes, + }; +} + +export interface ThreadSnapshotChunks { + readonly chunks: ReadonlyArray< + Extract + >; + readonly inlineBytes: number; + readonly messages: ReadonlyArray; + readonly activities: ReadonlyArray; +} + +export function buildSnapshotChunks(input: { + readonly snapshotId: string; + readonly head: ThreadHead; + readonly messages: ReadonlyArray; + readonly activities: ReadonlyArray; +}): ThreadSnapshotChunks { + let head: ThreadHead = { + ...input.head, + title: truncateUtf8(input.head.title, 8 * 1024, "…"), + session: + input.head.session === null + ? null + : { + ...input.head.session, + lastError: + input.head.session.lastError === null + ? null + : truncateUtf8( + input.head.session.lastError, + 32 * 1024, + "\n\n[Error detail omitted from the synced window.]", + ), + }, + activeProposedPlan: + input.head.activeProposedPlan === null + ? null + : { + ...input.head.activeProposedPlan, + planMarkdown: truncateUtf8( + input.head.activeProposedPlan.planMarkdown, + 64 * 1024, + "\n\n[Plan content omitted from the synced window.]", + ), + }, + }; + if (wireBytes(head) > THREAD_SYNC_CHUNK_TARGET_BYTES) { + head = { ...head, pendingRequests: [] }; + } + if (wireBytes(head) > THREAD_SYNC_CHUNK_TARGET_BYTES) { + throw new Error("Thread head exceeds the v2 snapshot chunk limit after compaction."); + } + const messages = input.messages.map(trimWindowMessage); + const selectedMessages: ThreadWindowMessage[] = []; + const selectedActivities: OrchestrationThreadActivity[] = []; + const headBytes = wireBytes(head); + const remainingBudget = THREAD_SYNC_INLINE_BUDGET_BYTES - headBytes; + const messageBudget = Math.floor(remainingBudget / 2); + const activityBudget = remainingBudget - messageBudget; + let messageBytes = 0; + let activityBytes = 0; + + for (const message of messages.toReversed()) { + const bytes = wireBytes(message); + if (messageBytes + bytes > messageBudget) break; + selectedMessages.unshift(message); + messageBytes += bytes; + } + for (const activity of input.activities.toReversed()) { + const bytes = wireBytes(activity); + if (activityBytes + bytes > activityBudget) break; + selectedActivities.unshift(activity); + activityBytes += bytes; + } + const inlineBytes = headBytes + messageBytes + activityBytes; + + const chunks: Array> = []; + let current: Extract = { + kind: "snapshot-chunk", + snapshotId: input.snapshotId, + index: 0, + head, + messages: [], + activities: [], + }; + const flush = () => { + chunks.push(current); + current = { + kind: "snapshot-chunk", + snapshotId: input.snapshotId, + index: chunks.length, + messages: [], + activities: [], + }; + }; + for (const message of selectedMessages) { + const candidate = { ...current, messages: [...current.messages, message] }; + if ( + wireBytes(candidate) > THREAD_SYNC_CHUNK_TARGET_BYTES && + (current.head !== undefined || current.messages.length > 0) + ) + flush(); + current = { ...current, messages: [...current.messages, message] }; + } + for (const activity of selectedActivities) { + const candidate = { + ...current, + activities: [...current.activities, activity], + }; + if ( + wireBytes(candidate) > THREAD_SYNC_CHUNK_TARGET_BYTES && + (current.head !== undefined || current.messages.length > 0 || current.activities.length > 0) + ) + flush(); + current = { ...current, activities: [...current.activities, activity] }; + } + flush(); + return { + chunks, + inlineBytes, + messages: selectedMessages, + activities: selectedActivities, + }; +} diff --git a/apps/server/src/orchestration/runtimeLayer.ts b/apps/server/src/orchestration/runtimeLayer.ts index a2ed5875950..4ae4e220d5b 100644 --- a/apps/server/src/orchestration/runtimeLayer.ts +++ b/apps/server/src/orchestration/runtimeLayer.ts @@ -5,6 +5,7 @@ import { OrchestrationEventStoreLive } from "../persistence/Layers/Orchestration import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; +import { ThreadSyncQueryLive } from "./Layers/ThreadSyncQuery.ts"; export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( OrchestrationEventStoreLive, @@ -17,6 +18,7 @@ export const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionP export const OrchestrationInfrastructureLayerLive = Layer.mergeAll( OrchestrationProjectionSnapshotQueryLive, + ThreadSyncQueryLive, OrchestrationEventInfrastructureLayerLive, OrchestrationProjectionPipelineLayerLive, ); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index 18d0e9aa578..ce7f5bef75e 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -64,6 +64,11 @@ const ReadFromSequenceRequestSchema = Schema.Struct({ sequenceExclusive: NonNegativeInt, limit: Schema.Number, }); +const ReadThreadFromSequenceRequestSchema = Schema.Struct({ + threadId: Schema.String, + sequenceExclusive: NonNegativeInt, + limit: Schema.Number, +}); const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000; const READ_PAGE_SIZE = 500; @@ -181,6 +186,32 @@ const makeEventStore = Effect.gen(function* () { `, }); + const readThreadEventRowsFromSequence = SqlSchema.findAll({ + Request: ReadThreadFromSequenceRequestSchema, + Result: OrchestrationEventPersistedRowSchema, + execute: (request) => + sql` + SELECT + sequence, + event_id AS "eventId", + event_type AS "type", + aggregate_kind AS "aggregateKind", + stream_id AS "aggregateId", + occurred_at AS "occurredAt", + command_id AS "commandId", + causation_event_id AS "causationEventId", + correlation_id AS "correlationId", + payload_json AS "payload", + metadata_json AS "metadata" + FROM orchestration_events + WHERE aggregate_kind = 'thread' + AND stream_id = ${request.threadId} + AND sequence > ${request.sequenceExclusive} + ORDER BY sequence ASC + LIMIT ${request.limit} + `, + }); + const append: OrchestrationEventStoreShape["append"] = (event) => appendEventRow({ eventId: event.eventId, @@ -260,9 +291,66 @@ const makeEventStore = Effect.gen(function* () { return readPage(sequenceExclusive, normalizedLimit); }; + const readThreadFromSequence: OrchestrationEventStoreShape["readThreadFromSequence"] = ( + threadId, + sequenceExclusive, + limit = DEFAULT_READ_FROM_SEQUENCE_LIMIT, + ) => { + const normalizedLimit = Math.max(0, Math.floor(limit)); + if (normalizedLimit === 0) { + return Stream.empty; + } + const readPage = ( + cursor: number, + remaining: number, + ): Stream.Stream => + Stream.fromEffect( + readThreadEventRowsFromSequence({ + threadId, + sequenceExclusive: cursor, + limit: Math.min(remaining, READ_PAGE_SIZE), + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "OrchestrationEventStore.readThreadFromSequence:query", + "OrchestrationEventStore.readThreadFromSequence:decodeRows", + ), + ), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeEvent(row).pipe( + Effect.mapError( + toPersistenceDecodeError( + "OrchestrationEventStore.readThreadFromSequence:rowToEvent", + ), + ), + ), + ), + ), + ), + ).pipe( + Stream.flatMap((events) => { + if (events.length === 0) { + return Stream.empty; + } + const nextRemaining = remaining - events.length; + if (nextRemaining <= 0) { + return Stream.fromIterable(events); + } + return Stream.concat( + Stream.fromIterable(events), + readPage(events[events.length - 1]!.sequence, nextRemaining), + ); + }), + ); + + return readPage(sequenceExclusive, normalizedLimit); + }; + return { append, readFromSequence, + readThreadFromSequence, readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), } satisfies OrchestrationEventStoreShape; }); diff --git a/apps/server/src/persistence/Services/OrchestrationEventStore.ts b/apps/server/src/persistence/Services/OrchestrationEventStore.ts index 8b465e7713e..72b0a2c4be5 100644 --- a/apps/server/src/persistence/Services/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Services/OrchestrationEventStore.ts @@ -46,6 +46,22 @@ export interface OrchestrationEventStoreShape { limit?: number, ) => Stream.Stream; + /** + * Read events for a single thread aggregate after the provided sequence, + * ordered by global sequence ascending. Backs incremental thread catch-up + * (Fix 2) so a reconnecting client fetches only the delta, not the whole thread. + * + * @param threadId - Thread aggregate (stream) id. + * @param sequenceExclusive - Sequence cursor (exclusive). + * @param limit - Maximum number of events to emit. + * @returns Stream containing ordered events for the thread. + */ + readonly readThreadFromSequence: ( + threadId: string, + sequenceExclusive: number, + limit?: number, + ) => Stream.Stream; + /** * Read all events from the beginning of the stream. * diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 40ed694723d..6d669f9bf3e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -459,6 +459,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const orchestrationEngine = { readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), } satisfies OrchestrationEngineShape; @@ -648,6 +649,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }), Layer.succeed(OrchestrationEngineService, { readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, dispatch: () => Effect.succeed({ sequence: 1 }), streamDomainEvents: Stream.fromQueue(events), } satisfies OrchestrationEngineShape), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 347c2920792..42e92461e5a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -77,6 +77,7 @@ import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; +import { ThreadSyncQuery } from "./orchestration/Services/ThreadSyncQuery.ts"; import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; @@ -673,12 +674,19 @@ const buildAppUnderTest = (options?: { ), ), Layer.provide( - Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ - readEvents: () => Stream.empty, - dispatch: () => Effect.succeed({ sequence: 0 }), - streamDomainEvents: Stream.empty, - ...options?.layers?.orchestrationEngine, - }), + Layer.mergeAll( + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 0 }), + streamDomainEvents: Stream.empty, + ...options?.layers?.orchestrationEngine, + }), + Layer.mock(ThreadSyncQuery)({ + getTail: () => Effect.succeed(Option.none()), + getHistoryPage: () => Effect.succeed({ currentHistoryEpoch: 0, page: null }), + }), + ), ), Layer.provide( Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 1a331bc717f..2ca5af1cd2b 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -163,6 +163,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), @@ -206,6 +207,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), @@ -255,6 +257,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, dispatch: (command) => Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( Effect.as({ sequence: 1 }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fafdbfa6814..348ae5067f6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,12 +1,14 @@ import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { @@ -25,6 +27,8 @@ import { type DiscoveredLocalServerList, EventId, type OrchestrationCommand, + type CompressionCodec, + layerCompressedJson, type GitActionProgressEvent, type GitManagerServiceError, OrchestrationDispatchCommandError, @@ -51,6 +55,8 @@ import { AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, EnvironmentAuthorizationError, + type OrchestrationThread, + type OrchestrationThreadV2StreamItem, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -61,7 +67,9 @@ import { } from "@t3tools/contracts"; import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; -import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; +import { RpcServer } from "effect/unstable/rpc"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import * as zlib from "node:zlib"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as ServerConfig from "./config.ts"; @@ -70,6 +78,13 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ThreadSyncQuery from "./orchestration/Services/ThreadSyncQuery.ts"; +import { + buildSnapshotChunks, + externalizeActivities, + externalizeThreadEvent, + trimWindowMessage, +} from "./orchestration/ThreadSyncWire.ts"; import { observeRpcEffect as instrumentRpcEffect, observeRpcStream as instrumentRpcStream, @@ -115,6 +130,7 @@ import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); +const isOrchestrationGetSnapshotError = Schema.is(OrchestrationGetSnapshotError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -252,6 +268,31 @@ function projectSetupScriptCompatibilityDetail( } } +// Incremental thread catch-up (Fix 2): when a reconnecting client is more than +// this many events behind, replaying the delta is no longer cheaper than a fresh +// (compacted) snapshot, so the server falls back to a full snapshot. +const THREAD_CATCHUP_MAX_EVENTS = 5_000; + +// Loose wire shape for the thread-stream head (snapshot | catch-up | event). +// Structurally matches `OrchestrationThreadStreamItem` but carries plain `number` +// sequences (the projection watermark is a `number`); the RPC layer validates the +// outgoing frames against the schema at runtime. Kept local so both head branches +// unify to one `Stream` type. +type ThreadStreamHeadItem = + | { + readonly kind: "snapshot"; + readonly snapshot: { + readonly snapshotSequence: number; + readonly thread: OrchestrationThread; + }; + } + | { + readonly kind: "catchup"; + readonly fromSequence: number; + readonly toSequence: number; + } + | { readonly kind: "event"; readonly event: OrchestrationEvent }; + function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< OrchestrationEvent, { @@ -274,16 +315,24 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< ); } +// Thread events that are SAFE to carry in a catch-up delta: detail events (which +// mutate the client's thread) plus the "*-requested" events the client reducer +// treats as pure no-ops. A non-delta-safe thread event in the window (deleted / +// archived / meta-updated / mode change) alters existence or metadata a delta +// can't convey and must force a full snapshot — but these no-ops must NOT, or an +// approval-parked thread would re-download in full on every reconnect (Fix 2 review). const PROVIDER_STATUS_DEBOUNCE_MS = 200; const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope], + [ORCHESTRATION_WS_METHODS.getThreadHistoryPage, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.replayEvents, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], + [ORCHESTRATION_WS_METHODS.subscribeThreadV2, AuthOrchestrationReadScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], @@ -396,6 +445,7 @@ const makeWsRpcLayer = ( const currentSessionId = currentSession.sessionId; const crypto = yield* Crypto.Crypto; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const threadSyncQuery = yield* ThreadSyncQuery.ThreadSyncQuery; const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; @@ -1038,6 +1088,37 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.getThreadHistoryPage]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getThreadHistoryPage, + Effect.gen(function* () { + const result = yield* threadSyncQuery.getHistoryPage(input); + if (result.page === null) { + return yield* new OrchestrationGetSnapshotError({ + message: "Thread history changed; reload the tail window.", + cause: { + requestedHistoryEpoch: input.historyEpoch, + currentHistoryEpoch: result.currentHistoryEpoch, + }, + }); + } + return { + ...result.page, + messages: result.page.messages.map(trimWindowMessage), + activities: yield* externalizeActivities(result.page.activities), + }; + }).pipe( + Effect.mapError((cause) => + isOrchestrationGetSnapshotError(cause) + ? cause + : new OrchestrationGetSnapshotError({ + message: `Failed to load thread history for ${input.threadId}`, + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => observeRpcEffect( ORCHESTRATION_WS_METHODS.replayEvents, @@ -1238,6 +1319,245 @@ const makeWsRpcLayer = ( }), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.subscribeThreadV2]: (input) => + observeRpcStreamEffect( + ORCHESTRATION_WS_METHODS.subscribeThreadV2, + Effect.gen(function* () { + const rawLive = orchestrationEngine.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.aggregateKind === "thread" && event.aggregateId === input.threadId, + ), + ); + const liveQueue = yield* Queue.dropping(2_048); + const overflow = yield* Deferred.make(); + yield* rawLive.pipe( + Stream.runForEach((event) => + Queue.offer(liveQueue, event).pipe( + Effect.flatMap((accepted) => + accepted + ? Effect.void + : Deferred.succeed(overflow, undefined).pipe(Effect.asVoid), + ), + ), + ), + Effect.forkScoped, + ); + + const tail = yield* threadSyncQuery.getTail(input.threadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: `Failed to load thread tail ${input.threadId}`, + cause, + }), + ), + ); + if (Option.isNone(tail)) { + // A cached client reconnecting after the thread was hard-deleted + // must receive the terminal event (so it can clean up its cache) + // rather than an error it will retry forever. The tail query only + // returns nothing for deleted threads; replay the lifecycle + // event from the log when the client holds a resume cursor. + if (input.sinceSequence !== undefined) { + // Page to the END of the thread's log: the deletion is always + // its final event, and a single capped read would miss it when + // more than one page of events accumulated since the cursor. + const deletedEvent = yield* Effect.gen(function* () { + let cursor = input.sinceSequence ?? 0; + for (;;) { + const page = yield* Stream.runCollect( + orchestrationEngine.readThreadEvents( + input.threadId, + cursor, + THREAD_CATCHUP_MAX_EVENTS, + ), + ).pipe( + Effect.map((events) => Array.from(events)), + Effect.catch(() => Effect.succeed([] as Array)), + ); + const found = page.find((event) => event.type === "thread.deleted"); + if (found !== undefined) return found; + if (page.length < THREAD_CATCHUP_MAX_EVENTS) return undefined; + cursor = page[page.length - 1]!.sequence; + } + }); + if (deletedEvent !== undefined) { + const enriched = yield* enrichOrchestrationEvents([deletedEvent]); + return Stream.fromIterable([ + { + kind: "catchup" as const, + historyEpoch: input.historyEpoch ?? 0, + fromSequence: input.sinceSequence, + toSequence: deletedEvent.sequence, + eventCount: enriched.length, + }, + ...enriched.map((event) => ({ kind: "event" as const, event })), + ]); + } + } + return yield* new OrchestrationGetSnapshotError({ + message: `Thread ${input.threadId} was not found`, + cause: input.threadId, + }); + } + + const snapshot = tail.value; + const streamHead: ReadonlyArray = yield* Effect.gen( + function* () { + if ( + input.sinceSequence !== undefined && + input.historyEpoch === snapshot.historyEpoch && + input.sinceSequence <= snapshot.watermark + ) { + const rawCatchup = yield* Stream.runCollect( + orchestrationEngine.readThreadEvents( + input.threadId, + input.sinceSequence, + THREAD_CATCHUP_MAX_EVENTS + 1, + ), + ).pipe(Effect.map((events) => Array.from(events))); + const catchup = rawCatchup.filter( + (event) => event.sequence <= snapshot.watermark, + ); + if ( + catchup.length <= THREAD_CATCHUP_MAX_EVENTS && + catchup.every((event) => event.type !== "thread.reverted") + ) { + const enriched = yield* enrichOrchestrationEvents(catchup); + const externalized = yield* Effect.forEach(enriched, externalizeThreadEvent, { + concurrency: 4, + }); + return [ + { + kind: "catchup" as const, + historyEpoch: snapshot.historyEpoch, + fromSequence: input.sinceSequence, + toSequence: snapshot.watermark, + eventCount: externalized.length, + }, + ...externalized.map((event) => ({ + kind: "event" as const, + event, + })), + ]; + } + } + + const head = { + ...snapshot.head, + pendingRequests: yield* externalizeActivities(snapshot.head.pendingRequests), + }; + const activities = yield* externalizeActivities(snapshot.activities); + const snapshotId = yield* crypto.randomUUIDv4; + const built = buildSnapshotChunks({ + snapshotId, + head, + messages: snapshot.messages, + activities, + }); + const oldestMessage = built.messages[0]; + const oldestActivity = built.activities[0]; + return [ + { + kind: "snapshot-start" as const, + snapshotId, + historyEpoch: snapshot.historyEpoch, + watermark: snapshot.watermark, + chunkCount: built.chunks.length, + inlineBytes: built.inlineBytes, + }, + ...built.chunks, + { + kind: "snapshot-complete" as const, + snapshotId, + historyEpoch: snapshot.historyEpoch, + lastAppliedSequence: snapshot.watermark, + before: { + message: + oldestMessage === undefined + ? null + : { + createdAt: oldestMessage.createdAt, + messageId: oldestMessage.id, + }, + activity: + oldestActivity === undefined + ? null + : { + createdAt: oldestActivity.createdAt, + activityId: oldestActivity.id, + }, + }, + hasOlderMessages: snapshot.head.counts.messages > built.messages.length, + hasOlderActivities: snapshot.head.counts.activities > built.activities.length, + }, + ]; + }, + ).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: `Failed to prepare thread tail ${input.threadId}`, + cause, + }), + ), + ); + + const lastSentSequence = yield* Ref.make(snapshot.watermark); + const liveEvents = Stream.fromQueue(liveQueue).pipe( + Stream.filter((event) => event.sequence > snapshot.watermark), + Stream.mapEffect((event) => { + if (event.type === "thread.reverted") { + return Effect.succeed({ + kind: "resync-required" as const, + reason: "history-invalidated" as const, + }); + } + return externalizeThreadEvent(event).pipe( + Effect.tap((externalized) => Ref.set(lastSentSequence, externalized.sequence)), + Effect.map( + (externalized): OrchestrationThreadV2StreamItem => ({ + kind: "event" as const, + event: externalized, + }), + ), + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: `Failed to prepare live thread event ${input.threadId}`, + cause, + }), + ), + ); + }), + ); + const overflowEvent = Stream.fromEffect(Deferred.await(overflow)).pipe( + Stream.map( + (): OrchestrationThreadV2StreamItem => ({ + kind: "resync-required" as const, + reason: "live-buffer-overflow" as const, + }), + ), + ); + const keepalives = Stream.fromEffectSchedule( + Ref.get(lastSentSequence), + Schedule.spaced("15 seconds"), + ).pipe( + Stream.map((sequence) => ({ + kind: "keepalive" as const, + sequence, + })), + ); + const boundedLive = liveEvents.pipe( + Stream.merge(overflowEvent), + Stream.merge(keepalives), + Stream.takeUntil((item) => item.kind === "resync-required"), + ); + return Stream.concat(Stream.fromIterable(streamHead), boundedLive); + }), + { "rpc.aggregate": "orchestration" }, + ), [WS_METHODS.serverGetConfig]: (_input) => observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server", @@ -1859,66 +2179,82 @@ const makeWsRpcLayer = ( }), ); +// Compression is capability-gated on /ws-compressed. /ws stays byte-for-byte +// JSON compatible with pre-negotiation clients. +const nodeGzipCodec: CompressionCodec = { + compressSync: (b) => zlib.gzipSync(b, { level: 4 }), + decompressSync: (b) => zlib.gunzipSync(b), + threshold: 1024, +}; + export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - return HttpRouter.add( - "GET", - "/ws", - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const sessions = yield* SessionStore.SessionStore; - const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("internal_error", error), - ), - ); - const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { - disableTracing: true, - }).pipe( - Effect.provide( - makeWsRpcLayer(session, previewAutomationBroker).pipe( - Layer.provideMerge(RpcSerialization.layerJson), - Layer.provide(ProviderMaintenanceRunner.layer), - Layer.provide( - SourceControlDiscovery.layer.pipe( - Layer.provide( - SourceControlProviderRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll( - AzureDevOpsCli.layer, - BitbucketApi.layer, - GitHubCli.layer, - GitLabCli.layer, + const makeRoute = ( + path: "/ws" | "/ws-compressed", + serialization: Layer.Layer, + ) => + HttpRouter.add( + "GET", + path, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), + ); + const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { + disableTracing: true, + }).pipe( + Effect.provide( + makeWsRpcLayer(session, previewAutomationBroker).pipe( + Layer.provideMerge(serialization), + Layer.provide(ProviderMaintenanceRunner.layer), + Layer.provide( + SourceControlDiscovery.layer.pipe( + Layer.provide( + SourceControlProviderRegistry.layer.pipe( + Layer.provide( + Layer.mergeAll( + AzureDevOpsCli.layer, + BitbucketApi.layer, + GitHubCli.layer, + GitLabCli.layer, + ), + ), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provide( + VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), ), - ), - Layer.provideMerge(GitVcsDriver.layer), - Layer.provide( - VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), ), ), + Layer.provide(VcsProcess.layer), ), - Layer.provide(VcsProcess.layer), ), ), ), - ), - ); - return yield* Effect.acquireUseRelease( - sessions.markConnected(session.sessionId), - () => rpcWebSocketHttpEffect, - () => sessions.markDisconnected(session.sessionId), - ); - }).pipe( - Effect.catchTags({ - EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, - EnvironmentInternalError: HttpServerRespondable.toResponse, - }), - ), + ); + return yield* Effect.acquireUseRelease( + sessions.markConnected(session.sessionId), + () => rpcWebSocketHttpEffect, + () => sessions.markDisconnected(session.sessionId), + ); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + }), + ), + ); + return Layer.mergeAll( + makeRoute("/ws", RpcSerialization.layerJson), + makeRoute("/ws-compressed", layerCompressedJson(nodeGzipCodec)), ); }), ); diff --git a/apps/web/package.json b/apps/web/package.json index 5a1579a478b..96f0099993a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,6 +38,7 @@ "jose": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", + "pako": "^2.1.0", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", @@ -55,6 +56,7 @@ "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", "@types/babel__core": "^7.20.5", + "@types/pako": "^2.0.3", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", "@vercel/config": "^0.3.0", diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 04d237a5030..054f111b545 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -21,6 +21,7 @@ import { EnvironmentId, OrchestrationShellSnapshot, OrchestrationThreadDetailSnapshot, + WindowedOrchestrationThread, ServerConfig, ThreadId, VcsListRefsResult, @@ -49,14 +50,14 @@ const StoredShellSnapshot = Schema.Struct({ snapshot: OrchestrationShellSnapshot, }); const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); -// v2 stores the snapshot sequence alongside the thread so a warm cache can -// resume via `afterSequence` instead of re-downloading the full thread body. -// Older v1 entries (no sequence) fail to decode and are treated as a cold cache. +// v3 stores either a complete legacy detail snapshot (with the sequence a warm +// cache resumes from via `afterSequence`) or a bounded thread-sync-v2 window. +// Older entries fail to decode and are treated as a cold cache. const StoredThreadSnapshot = Schema.Struct({ - schemaVersion: Schema.Literal(2), + schemaVersion: Schema.Literal(3), environmentId: EnvironmentId, threadId: ThreadId, - snapshot: OrchestrationThreadDetailSnapshot, + snapshot: Schema.Union([OrchestrationThreadDetailSnapshot, WindowedOrchestrationThread]), }); const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); const StoredServerConfig = Schema.Struct({ @@ -538,36 +539,43 @@ export const connectionStorageLayer = Layer.effectContext( threadCacheKey(environmentId, threadId), ).pipe( Effect.flatMap((raw) => { + type StoredSnapshot = OrchestrationThreadDetailSnapshot | WindowedOrchestrationThread; if (typeof raw !== "string") { - return Effect.succeed(Option.none()); + return Effect.succeed(Option.none()); } return decodeStoredThreadSnapshot(raw).pipe( - Effect.mapError((cause) => persistenceError("load-thread", cause)), Effect.map((stored) => stored.environmentId === environmentId && stored.threadId === threadId - ? Option.some(stored.snapshot) - : Option.none(), + ? Option.some(stored.snapshot) + : Option.none(), + ), + // A record from an older schema version is a cache miss, not an + // error — and it must be EVICTED, or every subsequent open of the + // thread re-decodes the same incompatible record. + Effect.catch(() => + removeDatabaseValue( + database, + THREAD_STORE_NAME, + threadCacheKey(environmentId, threadId), + ).pipe(Effect.ignore, Effect.as(Option.none())), ), ); }), - Effect.mapError((cause) => - cause._tag === "ConnectionPersistenceError" - ? cause - : persistenceError("load-thread", cause), - ), + Effect.mapError((cause) => persistenceError("load-thread", cause)), ), saveThread: (environmentId, snapshot) => Effect.gen(function* () { + const threadId = "head" in snapshot ? snapshot.head.id : snapshot.thread.id; const encoded = yield* encodeStoredThreadSnapshot({ - schemaVersion: 2, + schemaVersion: 3, environmentId, - threadId: snapshot.thread.id, + threadId, snapshot, }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); yield* writeDatabaseValue( database, THREAD_STORE_NAME, - threadCacheKey(environmentId, snapshot.thread.id), + threadCacheKey(environmentId, threadId), encoded, ); }).pipe( diff --git a/apps/web/src/environments/primary/bootstrap.test.ts b/apps/web/src/environments/primary/bootstrap.test.ts index e200ecf2ff8..2a69fdb6172 100644 --- a/apps/web/src/environments/primary/bootstrap.test.ts +++ b/apps/web/src/environments/primary/bootstrap.test.ts @@ -26,6 +26,8 @@ const BASE_ENVIRONMENT = { capabilities: { repositoryIdentity: true, }, + rpcTransports: [{ kind: "json", path: "/ws" }], + threadSyncVersions: [1], } satisfies ExecutionEnvironmentDescriptor; let disposeHttpTest: (() => Promise) | undefined; diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index 3836d2a3916..6244430f4cc 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -2,7 +2,9 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import type * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Socket from "effect/unstable/socket/Socket"; +import * as pako from "pako"; +import { type CompressionCodec, RpcCompressionCodec } from "@t3tools/contracts"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; import * as PrimaryEnvironmentHttpClient from "../environments/primary/httpClient"; @@ -24,10 +26,22 @@ const relayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig( client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web", }).pipe(Layer.provide(httpClientLayer)); +// Browser gzip codec for the /ws RPC (Fix 1), matching the server's node:zlib +// gzip and the mobile pako codec. pako is synchronous. The desktop app bundles +// this web renderer, so this also gives the desktop UI the codec it needs to +// decode a compressing server's frames. +const pakoCodec: CompressionCodec = { + compressSync: (b) => pako.gzip(b), + decompressSync: (b) => pako.inflate(b), + threshold: 1024, +}; +const compressionCodecLayer = Layer.succeed(RpcCompressionCodec, pakoCodec); + type RuntimeLayerSource = | typeof httpClientLayer | typeof browserCryptoLayer | typeof Socket.layerWebSocketConstructorGlobal + | typeof compressionCodecLayer | typeof relayTracingLayer | ReturnType; @@ -58,6 +72,7 @@ const runtimeLayer = Layer.mergeAll( httpClientLayer, browserCryptoLayer, Socket.layerWebSocketConstructorGlobal, + compressionCodecLayer, relayTracingLayer, managedRelayClientLayer(configuredRelayUrl()).pipe( Layer.provide(Layer.mergeAll(httpClientLayer, browserCryptoLayer)), diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index 1d2c6c6cca7..5824292a448 100644 --- a/packages/client-runtime/src/authorization/layer.test.ts +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -180,10 +180,16 @@ describe("RemoteEnvironmentAuthorization", () => { expect(authorized.socketUrl).toContain("wsTicket=cached-ticket"); expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); - expect(harness.fetch.calls).toHaveLength(1); - expect(String(harness.fetch.calls[0]?.[0])).toBe( - "https://environment.example.test/api/auth/websocket-ticket", + // Reuse may add a best-effort transport-negotiation probe to the + // ENVIRONMENT itself (never the relay); the ticket call must be the only + // auth traffic. + const ticketCalls = harness.fetch.calls.filter((call) => + String(call[0]).endsWith("/api/auth/websocket-ticket"), ); + expect(ticketCalls).toHaveLength(1); + // Offline-tolerant: the failed descriptor probe must not have broken + // token reuse (this test's fetch mock rejects every other URL). + expect(authorized.rpcTransport).toEqual({ kind: "json", path: "/ws" }); }), ); diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index 624ecf7f672..24c1c36675f 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -1,5 +1,9 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, RpcCompressionCodec } from "@t3tools/contracts"; import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; +import type { + ExecutionEnvironmentDescriptor, + ExecutionEnvironmentRpcTransport, +} from "@t3tools/contracts"; import { exchangeRemoteDpopAccessToken, type RemoteEnvironmentAuthError, @@ -10,6 +14,11 @@ import { environmentMismatchError, mapRemoteEnvironmentError } from "../connecti import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { + applyRpcTransport, + selectRpcTransport, + selectThreadSyncVersion, +} from "../environment/transport.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; import * as TokenStore from "./tokenStore.ts"; @@ -35,12 +44,17 @@ export interface AuthorizedRemoteEnvironment { readonly label: string; readonly httpBaseUrl: string; readonly socketUrl: string; + readonly rpcTransport?: ExecutionEnvironmentRpcTransport; + readonly threadSyncVersion?: 1 | 2; readonly httpAuthorization: PreparedHttpAuthorization; } export class RemoteEnvironmentAuthorization extends Context.Service< RemoteEnvironmentAuthorization, { + readonly getDescriptor?: ( + httpBaseUrl: string, + ) => Effect.Effect; readonly authorizeBearer: (input: { readonly expectedEnvironmentId: EnvironmentId; readonly httpBaseUrl: string; @@ -125,11 +139,15 @@ export const make = Effect.gen(function* () { Effect.mapError(mapRemoteEnvironmentError), Effect.provideService(HttpClient.HttpClient, httpClient), ); + const hasCompressionCodec = (yield* RpcCompressionCodec) !== null; + const rpcTransport = selectRpcTransport(descriptor, { hasCompressionCodec }); return { environmentId: descriptor.environmentId, label: descriptor.label, httpBaseUrl: input.httpBaseUrl, - socketUrl, + socketUrl: applyRpcTransport(socketUrl, rpcTransport), + rpcTransport, + threadSyncVersion: selectThreadSyncVersion(descriptor), httpAuthorization: { _tag: "Bearer" as const, token: input.bearerToken, @@ -198,12 +216,38 @@ export const make = Effect.gen(function* () { }); const cachedSocket = yield* createDpopSocketUrl(cached.value).pipe(Effect.result); if (Result.isSuccess(cachedSocket)) { + // Best-effort transport negotiation: the whole point of this branch is + // reusing the persisted token WITHOUT any network round trip, so a + // failed or unreachable descriptor fetch must not break it — fall back + // to the legacy JSON transport and let a later full authorization + // upgrade the transport. + const descriptor = yield* fetchDescriptor(cached.value.endpoint.httpBaseUrl).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.option, + ); + if ( + Option.isSome(descriptor) && + descriptor.value.environmentId !== input.expectedEnvironmentId + ) { + return yield* environmentMismatchError({ + expected: input.expectedEnvironmentId, + actual: descriptor.value.environmentId, + }); + } + const hasCompressionCodec = (yield* RpcCompressionCodec) !== null; + const rpcTransport = Option.isSome(descriptor) + ? selectRpcTransport(descriptor.value, { hasCompressionCodec }) + : ({ kind: "json", path: "/ws" } as const); yield* resetCachedEndpointFailures(input.expectedEnvironmentId); return { environmentId: cached.value.environmentId, label: cached.value.label, httpBaseUrl: cached.value.endpoint.httpBaseUrl, - socketUrl: cachedSocket.success, + socketUrl: applyRpcTransport(cachedSocket.success, rpcTransport), + rpcTransport, + threadSyncVersion: Option.isSome(descriptor) + ? selectThreadSyncVersion(descriptor.value) + : (1 as const), httpAuthorization: { _tag: "Dpop" as const, accessToken: cached.value.accessToken, @@ -276,6 +320,8 @@ export const make = Effect.gen(function* () { dpopThumbprint: thumbprint, }); const socketUrl = yield* createDpopSocketUrl(token).pipe(Effect.mapError(mapDpopSocketError)); + const hasCompressionCodec = (yield* RpcCompressionCodec) !== null; + const rpcTransport = selectRpcTransport(descriptor, { hasCompressionCodec }); yield* tokenStore .put(token) .pipe(Effect.withSpan("environment.authorization.accessToken.persist")); @@ -283,7 +329,9 @@ export const make = Effect.gen(function* () { environmentId: descriptor.environmentId, label: descriptor.label, httpBaseUrl: bootstrap.endpoint.httpBaseUrl, - socketUrl, + socketUrl: applyRpcTransport(socketUrl, rpcTransport), + rpcTransport, + threadSyncVersion: selectThreadSyncVersion(descriptor), httpAuthorization: { _tag: "Dpop" as const, accessToken: token.accessToken, @@ -293,6 +341,8 @@ export const make = Effect.gen(function* () { ); return RemoteEnvironmentAuthorization.of({ + getDescriptor: (httpBaseUrl) => + fetchDescriptor(httpBaseUrl).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)), authorizeBearer, authorizeDpop: (input) => authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")), diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts index fbcb302ed13..d0f3b373be9 100644 --- a/packages/client-runtime/src/connection/model.ts +++ b/packages/client-runtime/src/connection/model.ts @@ -1,4 +1,4 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, type ExecutionEnvironmentRpcTransport } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; const ConnectionTargetBase = { @@ -118,6 +118,8 @@ export interface PreparedConnection { readonly label: string; readonly httpBaseUrl: string; readonly socketUrl: string; + readonly rpcTransport?: ExecutionEnvironmentRpcTransport; + readonly threadSyncVersion?: 1 | 2; readonly httpAuthorization: PreparedHttpAuthorization | null; readonly target: ConnectionTarget; } diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index d0375e55556..17b371b04bb 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -217,6 +217,8 @@ describe("ConnectionResolver", () => { label: "Primary", httpBaseUrl: "http://127.0.0.1:3777", socketUrl: "ws://127.0.0.1:3777/ws", + rpcTransport: { kind: "json", path: "/ws" }, + threadSyncVersion: 1, httpAuthorization: null, target, }); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index c219bde092c..7a76a7f87a5 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -1,3 +1,4 @@ +import { RpcCompressionCodec } from "@t3tools/contracts"; import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Context from "effect/Context"; @@ -7,6 +8,11 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; +import { + applyRpcTransport, + selectRpcTransport, + selectThreadSyncVersion, +} from "../environment/transport.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; import { @@ -63,11 +69,33 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary ) { const bearerToken = yield* auth.bearerToken; if (Option.isNone(bearerToken)) { + const descriptor = + remote.getDescriptor === undefined + ? { + environmentId: target.environmentId, + label: target.label, + platform: { os: "unknown" as const, arch: "other" as const }, + serverVersion: "unknown", + capabilities: { repositoryIdentity: false }, + rpcTransports: [{ kind: "json" as const, path: "/ws" }], + threadSyncVersions: [1 as const], + } + : yield* remote.getDescriptor(target.httpBaseUrl); + if (descriptor.environmentId !== target.environmentId) { + return yield* environmentMismatchError({ + expected: target.environmentId, + actual: descriptor.environmentId, + }); + } + const hasCompressionCodec = (yield* RpcCompressionCodec) !== null; + const rpcTransport = selectRpcTransport(descriptor, { hasCompressionCodec }); return { environmentId: target.environmentId, label: target.label, httpBaseUrl: target.httpBaseUrl, - socketUrl: primarySocketUrl(target), + socketUrl: applyRpcTransport(primarySocketUrl(target), rpcTransport), + rpcTransport, + threadSyncVersion: selectThreadSyncVersion(descriptor), httpAuthorization: null, target, } satisfies PreparedConnection; @@ -132,6 +160,8 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") label: authorized.label, httpBaseUrl: authorized.httpBaseUrl, socketUrl: authorized.socketUrl, + rpcTransport: authorized.rpcTransport ?? { kind: "json", path: "/ws" }, + threadSyncVersion: authorized.threadSyncVersion ?? 1, httpAuthorization: authorized.httpAuthorization, target, } satisfies PreparedConnection; @@ -177,6 +207,11 @@ const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(f label: authorized.label, httpBaseUrl: authorized.httpBaseUrl, socketUrl: authorized.socketUrl, + rpcTransport: authorized.rpcTransport ?? { + kind: "json", + path: "/ws", + }, + threadSyncVersion: authorized.threadSyncVersion ?? 1, httpAuthorization: authorized.httpAuthorization, target, } satisfies PreparedConnection; @@ -235,6 +270,8 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct label: authorized.label, httpBaseUrl: authorized.httpBaseUrl, socketUrl: authorized.socketUrl, + rpcTransport: authorized.rpcTransport ?? { kind: "json", path: "/ws" }, + threadSyncVersion: authorized.threadSyncVersion ?? 1, httpAuthorization: authorized.httpAuthorization, target, } satisfies PreparedConnection; diff --git a/packages/client-runtime/src/environment/index.ts b/packages/client-runtime/src/environment/index.ts index 03c6bf6e491..02068f91ccf 100644 --- a/packages/client-runtime/src/environment/index.ts +++ b/packages/client-runtime/src/environment/index.ts @@ -2,3 +2,4 @@ export * from "./descriptor.ts"; export * from "./endpoint.ts"; export * from "./knownEnvironment.ts"; export * from "./scoped.ts"; +export * from "./transport.ts"; diff --git a/packages/client-runtime/src/environment/transport.ts b/packages/client-runtime/src/environment/transport.ts new file mode 100644 index 00000000000..622cb06a236 --- /dev/null +++ b/packages/client-runtime/src/environment/transport.ts @@ -0,0 +1,40 @@ +import type { + ExecutionEnvironmentDescriptor, + ExecutionEnvironmentRpcTransport, +} from "@t3tools/contracts"; + +export function selectRpcTransport( + descriptor: ExecutionEnvironmentDescriptor, + options?: { readonly hasCompressionCodec?: boolean }, +): ExecutionEnvironmentRpcTransport { + // Only prefer the compressed transport when this platform actually provides + // a codec; otherwise a codec-less client would connect to the gzip endpoint + // and speak plain JSON at it. + const preferred = + options?.hasCompressionCodec === true + ? descriptor.rpcTransports?.find((transport) => transport.kind === "gzip-json") + : undefined; + return ( + preferred ?? + descriptor.rpcTransports?.find((transport) => transport.kind === "json") ?? { + kind: "json", + path: "/ws", + } + ); +} + +export function selectThreadSyncVersion(descriptor: ExecutionEnvironmentDescriptor): 1 | 2 { + return descriptor.threadSyncVersions?.includes(2) ? 2 : 1; +} + +export function applyRpcTransport( + socketUrl: string, + transport: ExecutionEnvironmentRpcTransport, +): string { + const url = new URL(socketUrl); + const path = transport.path.startsWith("/") ? transport.path : `/${transport.path}`; + // Replace only the trailing RPC segment so a reverse-proxied base path + // (e.g. wss://host/prefix/ws) keeps its prefix instead of being overwritten. + url.pathname = url.pathname.replace(/\/ws(-compressed)?\/?$/, "") + path; + return url.toString(); +} diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 8723ac06939..6f2e437c727 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -5,6 +5,7 @@ import { type ServerConfig, type ThreadId, type VcsListRefsResult, + type WindowedOrchestrationThread, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -53,6 +54,15 @@ export class ConnectionRegistrationStore extends Context.Service< } >()("@t3tools/client-runtime/platform/persistence/ConnectionRegistrationStore") {} +/** + * What the thread cache stores: either a complete legacy detail snapshot (with + * its `snapshotSequence` resume cursor) or a bounded thread-sync-v2 window + * (which carries its own `lastAppliedSequence`). Either way the client can + * resume the live subscription from the persisted cursor on reconnect or cold + * start instead of re-downloading the whole thread. + */ +export type CachedThreadSnapshot = OrchestrationThreadDetailSnapshot | WindowedOrchestrationThread; + export class EnvironmentCacheStore extends Context.Service< EnvironmentCacheStore, { @@ -66,13 +76,10 @@ export class EnvironmentCacheStore extends Context.Service< readonly loadThread: ( environmentId: EnvironmentId, threadId: ThreadId, - ) => Effect.Effect< - Option.Option, - ConnectionPersistenceError - >; + ) => Effect.Effect, ConnectionPersistenceError>; readonly saveThread: ( environmentId: EnvironmentId, - snapshot: OrchestrationThreadDetailSnapshot, + snapshot: CachedThreadSnapshot, ) => Effect.Effect; readonly removeThread: ( environmentId: EnvironmentId, diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 92892431e45..dbe05916f23 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -41,6 +41,7 @@ type RpcMethod = WsRpcProtocolClient[TTag]; export type EnvironmentSubscriptionRpcTag = | typeof ORCHESTRATION_WS_METHODS.subscribeShell | typeof ORCHESTRATION_WS_METHODS.subscribeThread + | typeof ORCHESTRATION_WS_METHODS.subscribeThreadV2 | typeof WS_METHODS.subscribeAuthAccess | typeof WS_METHODS.subscribeServerConfig | typeof WS_METHODS.subscribeServerLifecycle @@ -149,7 +150,12 @@ export function runStream( export function subscribe( tag: TTag, - input: EnvironmentRpcInput, + // A plain input, or an Effect that resolves the input freshly on every + // (re)subscribe. The Effect form lets a durable subscription send an + // up-to-date value on reconnect (Fix 2: a thread re-subscribe carries the + // client's current `sinceSequence` cursor so the server sends a delta, not a + // full re-download) instead of replaying the value captured at first connect. + input: EnvironmentRpcInput | Effect.Effect>, options?: { readonly onExpectedFailure?: ( cause: Cause.Cause>, @@ -161,6 +167,9 @@ export function subscribe( EnvironmentRpcStreamFailure, EnvironmentSupervisor > { + const resolveInput: Effect.Effect> = Effect.isEffect(input) + ? (input as Effect.Effect>) + : Effect.succeed(input); return Stream.unwrap( EnvironmentSupervisor.pipe( Effect.map((supervisor) => @@ -180,7 +189,7 @@ export function subscribe( EnvironmentRpcStreamFailure > => Stream.suspend(() => - method(input).pipe( + Stream.unwrap(Effect.map(resolveInput, (resolved) => method(resolved))).pipe( Stream.catchCause((cause) => { const hasOnlyExpectedFailures = cause.reasons.length > 0 && diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 7820c93a935..6a7ec3b6e5e 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -110,6 +110,8 @@ const SERVER_CONFIG: ServerConfigType = { capabilities: { repositoryIdentity: true, }, + rpcTransports: [{ kind: "json", path: "/ws" }], + threadSyncVersions: [1], }, auth: { policy: "loopback-browser", diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index f9594c1b7ca..002fc9cf26e 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -1,4 +1,9 @@ -import { type ServerConfig, WS_METHODS } from "@t3tools/contracts"; +import { + layerCompressedJson, + RpcCompressionCodec, + type ServerConfig, + WS_METHODS, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -66,6 +71,7 @@ function mapInitialConfigError(error: InitialConfigError): ConnectionAttemptErro export const make = Effect.gen(function* () { const webSocketConstructor = yield* Socket.WebSocketConstructor; + const codec = yield* RpcCompressionCodec; const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { yield* Effect.annotateCurrentSpan({ @@ -104,7 +110,9 @@ export const make = Effect.gen(function* () { Layer.provide( Layer.mergeAll( socketLayer, - RpcSerialization.layerJson, + connection.rpcTransport?.kind === "gzip-json" && codec !== null + ? layerCompressedJson(codec) + : RpcSerialization.layerJson, Layer.succeed(RpcClient.ConnectionHooks, hooks), ), ), diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index b601b59bfad..671a03a16d1 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -3,10 +3,10 @@ import type { OrchestrationMessage, OrchestrationProjectShell, OrchestrationShellSnapshot, - OrchestrationThread, OrchestrationThreadShell, ThreadId, } from "@t3tools/contracts"; +import type { ThreadDetailData } from "./windowedThread.ts"; export interface EnvironmentProject extends OrchestrationProjectShell { readonly environmentId: EnvironmentId; @@ -18,9 +18,9 @@ export interface EnvironmentThreadShell extends OrchestrationThreadShell { export type EnvironmentMessage = OrchestrationMessage; -export interface EnvironmentThread extends OrchestrationThread { +export type EnvironmentThread = ThreadDetailData & { readonly environmentId: EnvironmentId; -} +}; export function scopeProject( environmentId: EnvironmentId, @@ -38,7 +38,7 @@ export function scopeThreadShell( export function scopeThread( environmentId: EnvironmentId, - thread: OrchestrationThread, + thread: ThreadDetailData, ): EnvironmentThread { return { ...thread, environmentId }; } diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 430900bdbb0..31a32bb5bb6 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -4,7 +4,6 @@ import type { OrchestrationMessage, OrchestrationProposedPlan, OrchestrationSession, - OrchestrationThread, OrchestrationThreadActivity, ScopedThreadRef, } from "@t3tools/contracts"; @@ -16,6 +15,7 @@ import { scopeThread } from "./models.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threadState.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; +import type { ThreadDetailData } from "./windowedThread.ts"; const EMPTY_MESSAGES: ReadonlyArray = Object.freeze([]); const EMPTY_ACTIVITIES: ReadonlyArray = Object.freeze([]); @@ -82,7 +82,7 @@ export function createEnvironmentThreadDetailAtoms( const threadDetailAtomFamily = Atom.family((key: string) => { const ref = parseThreadKey(key); - let previousSource: OrchestrationThread | null = null; + let previousSource: ThreadDetailData | null = null; let previousValue: EnvironmentThread | null = null; return Atom.make((get) => { const source = Option.getOrNull(get(threadStateValueAtomFamily(key)).data); diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index 89be139e925..ae1131624c2 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -1,16 +1,22 @@ -import type { OrchestrationThread } from "@t3tools/contracts"; import * as Option from "effect/Option"; +import type { ThreadDetailData } from "./windowedThread.ts"; export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; export interface EnvironmentThreadState { - readonly data: Option.Option; + readonly data: Option.Option; readonly status: EnvironmentThreadStatus; readonly error: Option.Option; + readonly hasOlder?: boolean; + readonly loadingOlder?: boolean; + readonly loadOlder?: () => Promise; } export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { data: Option.none(), status: "empty", error: Option.none(), + hasOlder: false, + loadingOlder: false, + loadOlder: () => Promise.resolve(), }; diff --git a/packages/client-runtime/src/state/threads-sync-v2.test.ts b/packages/client-runtime/src/state/threads-sync-v2.test.ts new file mode 100644 index 00000000000..a3b96cc3310 --- /dev/null +++ b/packages/client-runtime/src/state/threads-sync-v2.test.ts @@ -0,0 +1,253 @@ +import { + EnvironmentId, + ORCHESTRATION_WS_METHODS, + OrchestrationThreadV2StreamItem, + ProjectId, + ProviderInstanceId, + ThreadId, + type ThreadHead, + type ThreadWindowMessage, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as RpcSession from "../rpc/session.ts"; +import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; +import { + EMPTY_ENVIRONMENT_THREAD_STATE, + makeEnvironmentThreadState, + type EnvironmentThreadState, +} from "./threads.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const THREAD_ID = ThreadId.make("thread-1"); + +const HEAD: ThreadHead = { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "V2 thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + session: null, + activeProposedPlan: null, + pendingRequests: [], + counts: { messages: 40, activities: 200 }, +}; + +const MESSAGE: ThreadWindowMessage = { + id: "message-1" as ThreadWindowMessage["id"], + role: "user", + text: "hello from the tail window", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:10:00.000Z", + updatedAt: "2026-04-01T00:10:00.000Z", +}; + +// Simulate the wire: every item is encoded to its JSON wire form and decoded +// back, exactly as the RPC serialization layer does. This catches mismatches +// that in-memory fixtures hide (undefined vs missing, branded types, dates). +const wireRoundTrip = (item: OrchestrationThreadV2StreamItem): OrchestrationThreadV2StreamItem => { + const encoded = Schema.encodeUnknownSync(OrchestrationThreadV2StreamItem)(item); + const json = JSON.parse(JSON.stringify(encoded)) as unknown; + return Schema.decodeUnknownSync(OrchestrationThreadV2StreamItem)(json); +}; + +// The exact head composition subscribeThreadV2 emits for a cold tail sync +// (apps/server/src/ws.ts): snapshot-start, chunks, snapshot-complete. +const v2SnapshotItems = (): ReadonlyArray => [ + { + kind: "snapshot-start", + snapshotId: "snap-1", + historyEpoch: 0, + watermark: 77, + chunkCount: 1, + inlineBytes: 1024, + }, + { + kind: "snapshot-chunk", + snapshotId: "snap-1", + index: 0, + head: HEAD, + messages: [MESSAGE], + activities: [], + }, + { + kind: "snapshot-complete", + snapshotId: "snap-1", + historyEpoch: 0, + lastAppliedSequence: 77, + before: { + message: { createdAt: MESSAGE.createdAt, messageId: MESSAGE.id }, + activity: null, + }, + hasOlderMessages: true, + hasOlderActivities: true, + }, +]; + +type TestInput = OrchestrationThreadV2StreamItem | Error; + +function testSession(client: WsRpcProtocolClient): RpcSession.RpcSession { + return { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + +function awaitThreadState( + observed: Queue.Queue, + predicate: (state: EnvironmentThreadState) => boolean, +) { + return Queue.take(observed).pipe( + Effect.repeat({ + until: predicate, + }), + ); +} + +const makeHarness = Effect.fn("TestEnvironmentThreadsV2.makeHarness")(function* () { + const inputs = yield* Queue.unbounded(); + const observed = yield* Queue.unbounded(); + const latest = yield* Ref.make(EMPTY_ENVIRONMENT_THREAD_STATE); + const retryCount = yield* Ref.make(0); + const subscriptionCount = yield* Ref.make(0); + const savedWindows = yield* Ref.make>([]); + const supervisorState = yield* SubscriptionRef.make( + AVAILABLE_CONNECTION_STATE, + ); + const streamFrom = (queue: Queue.Queue) => + Stream.fromQueue(queue).pipe( + Stream.mapEffect((input) => + input instanceof Error ? Effect.fail(input) : Effect.succeed(input), + ), + ); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeThreadV2]: () => + Stream.unwrap( + Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( + Effect.map(() => streamFrom(inputs)), + ), + ), + } as unknown as WsRpcProtocolClient; + const supervisorSession = yield* SubscriptionRef.make>( + Option.some(testSession(client)), + ); + const prepared = yield* SubscriptionRef.make>( + Option.some({ + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws", + rpcTransport: { kind: "json", path: "/ws" }, + threadSyncVersion: 2, + httpAuthorization: null, + target: TARGET, + }), + ); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: supervisorSession, + prepared, + connect: Effect.void, + disconnect: Effect.void, + retryNow: Ref.update(retryCount, (count) => count + 1), + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: (_environmentId, thread) => + Ref.update(savedWindows, (current) => [...current, thread]), + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const loaderCalls = yield* Ref.make(0); + const snapshotLoader = ThreadSnapshotLoader.of({ + load: () => Ref.update(loaderCalls, (count) => count + 1).pipe(Effect.as(Option.none())), + }); + const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ThreadSnapshotLoader, snapshotLoader), + ); + yield* SubscriptionRef.changes(threadState).pipe( + Stream.runForEach((state) => + Ref.set(latest, state).pipe(Effect.andThen(Queue.offer(observed, state))), + ), + Effect.forkScoped, + ); + + return { + inputs, + observed, + latest, + retryCount, + subscriptionCount, + savedWindows, + loaderCalls, + }; +}); + +describe("EnvironmentThreads v2 wire integration", () => { + it.effect("commits a wire-round-tripped tail snapshot and goes live", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + for (const item of v2SnapshotItems()) { + yield* Queue.offer(harness.inputs, wireRoundTrip(item)); + } + + const state = yield* awaitThreadState( + harness.observed, + (value) => value.status === "live" && Option.isSome(value.data), + ); + + expect(state.status).toBe("live"); + expect(Option.isSome(state.data)).toBe(true); + // No silent resync loop: the subscription must not have been retried. + expect(yield* Ref.get(harness.retryCount)).toBe(0); + expect(yield* Ref.get(harness.subscriptionCount)).toBe(1); + // v2 must not fall back to the legacy HTTP snapshot loader. + expect(yield* Ref.get(harness.loaderCalls)).toBe(0); + }), + ); +}); diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 8e982723d22..e47ddd574dd 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -166,8 +166,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o }) : Option.none(), ), - saveThread: (_environmentId, thread) => - Ref.update(savedThreads, (current) => [...current, thread]), + saveThread: (_environmentId, snapshot) => + "thread" in snapshot + ? Ref.update(savedThreads, (current) => [...current, snapshot]) + : Effect.die("Legacy sync test unexpectedly persisted a v2 window."), removeThread: (_environmentId, threadId) => Ref.update(removedThreads, (current) => [...current, threadId]), loadServerConfig: () => Effect.succeed(Option.none()), diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index fd5b425fa2a..76237b8f3b7 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -2,26 +2,41 @@ import { ORCHESTRATION_WS_METHODS, type EnvironmentId as EnvironmentIdType, type OrchestrationThread, + type OrchestrationThreadActivity, type OrchestrationThreadDetailSnapshot, + type OrchestrationThreadV2StreamItem, type OrchestrationThreadStreamItem, type ThreadId as ThreadIdType, + type ThreadHead, + type ThreadWindowMessage, + type WindowedOrchestrationThread, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts"; -import { connectionProjectionPhase } from "../connection/model.ts"; +import { connectionProjectionPhase, type PreparedConnection } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; -import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import { type CachedThreadSnapshot, EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribe } from "../rpc/client.ts"; import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; +import { + applyWindowedThreadEvent, + fromWindowSnapshot, + isWindowedThread, + mergeWindowHistoryPage, + toWindowSnapshot, + type ThreadDetailData, + type WindowedOrchestrationThreadState, +} from "./windowedThread.ts"; import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { @@ -30,10 +45,16 @@ import { type EnvironmentThreadStatus, } from "./threadState.ts"; -function statusWithoutLiveData(data: Option.Option): EnvironmentThreadStatus { +function statusWithoutLiveData(data: Option.Option): EnvironmentThreadStatus { return Option.isSome(data) ? "cached" : "empty"; } +function isWindowSnapshot( + value: OrchestrationThread | CachedThreadSnapshot, +): value is WindowedOrchestrationThread { + return "syncVersion" in value && value.syncVersion === 2 && "head" in value; +} + function formatThreadError(cause: Cause.Cause): string { const error = Cause.squash(cause); return error instanceof Error && error.message.trim().length > 0 @@ -56,37 +77,86 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make threadId, error: error.message, }), - Effect.as(Option.none()), + Effect.as(Option.none()), ), ), ); - const cachedThread = Option.map(cached, (snapshot) => snapshot.thread); + const cachedThread = Option.map( + cached, + (entry): ThreadDetailData => + isWindowSnapshot(entry) ? fromWindowSnapshot(entry) : entry.thread, + ); + let loadOlderImpl: () => Promise = () => Promise.resolve(); + const loadOlder = () => loadOlderImpl(); const state = yield* SubscriptionRef.make({ data: cachedThread, status: statusWithoutLiveData(cachedThread), error: Option.none(), + hasOlder: Option.match(cachedThread, { + onNone: () => false, + onSome: (thread) => + isWindowedThread(thread) && (thread.hasOlderMessages || thread.hasOlderActivities), + }), + loadingOlder: false, + loadOlder, }); - // Seed the resume cursor from the cached snapshot so a warm cache can catch up - // via `afterSequence` instead of re-downloading the full thread body. + // Seed the resume cursor from the persisted snapshot (legacy `snapshotSequence` + // or windowed `lastAppliedSequence`) so a cold start resumes incrementally + // instead of re-downloading the whole thread. + const cachedSequence = (entry: CachedThreadSnapshot): number => + isWindowSnapshot(entry) ? entry.lastAppliedSequence : entry.snapshotSequence; const lastSequence = yield* SubscriptionRef.make( - Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), + Option.match(cached, { + onNone: () => 0, + onSome: cachedSequence, + }), ); - const persistence = yield* Queue.sliding(1); + const persistence = yield* Queue.sliding<{ + readonly thread: ThreadDetailData; + readonly sequence: number; + }>(1); + // The last CONSISTENT {thread, sequence} pair committed by setThread. The scope + // finalizer persists THIS (Fix 2) rather than reading `state` and `lastSequence` + // from two separate refs — an interrupted apply can leave those torn (cursor + // advanced, thread not yet committed), and persisting a cursor ahead of its + // thread makes reconnect deltas skip the gap forever. A plain assignment is + // synchronous/uninterruptible, so this is always a matched pair. + let lastPersistable: { + readonly thread: ThreadDetailData; + readonly sequence: number; + } | null = Option.match(cachedThread, { + onNone: () => null, + onSome: (thread) => ({ + thread, + sequence: Option.match(cached, { + onNone: () => 0, + onSome: cachedSequence, + }), + }), + }); - const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( - snapshot: OrchestrationThreadDetailSnapshot, - ) { - yield* cache.saveThread(environmentId, snapshot).pipe( - Effect.catch((error) => - Effect.logWarning("Could not persist the thread cache.").pipe( - Effect.annotateLogs({ - environmentId, - threadId, - error: error.message, - }), + const persist = Effect.fn("EnvironmentThreadState.persist")(function* (entry: { + readonly thread: ThreadDetailData; + readonly sequence: number; + }) { + yield* cache + .saveThread( + environmentId, + isWindowedThread(entry.thread) + ? toWindowSnapshot(entry.thread) + : { snapshotSequence: entry.sequence, thread: entry.thread }, + ) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist the thread cache.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + ), ), - ), - ); + ); }); yield* Stream.fromQueue(persistence).pipe( @@ -121,25 +191,56 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make })); const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( - thread: OrchestrationThread, + inputThread: ThreadDetailData, + sequence: number, ) { - yield* SubscriptionRef.set(state, { - data: Option.some(thread), - status: "live", - error: Option.none(), - }); - // Persist the thread together with the sequence it reflects so the next warm - // cache can resume from exactly here. - const snapshotSequence = yield* SubscriptionRef.get(lastSequence); - yield* Queue.offer(persistence, { snapshotSequence, thread }); + const thread = isWindowedThread(inputThread) + ? { ...inputThread, lastAppliedSequence: sequence } + : inputThread; + // Advance the thread state and the cursor together under `uninterruptible` + // so an interrupt (e.g. a reconnect) can never leave `lastSequence` out of + // step with the committed thread (Fix 2c). A cursor AHEAD of its thread + // makes the reconnect delta skip the gap forever; a cursor BEHIND it lets a + // re-sent event slip past the dedup guard and be applied twice. + yield* Effect.uninterruptible( + Effect.gen(function* () { + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + data: Option.some(thread), + status: "live" as const, + error: Option.none(), + hasOlder: + isWindowedThread(thread) && (thread.hasOlderMessages || thread.hasOlderActivities), + })); + yield* SubscriptionRef.set(lastSequence, sequence); + }), + ); + yield* Queue.offer(persistence, { thread, sequence }); + lastPersistable = { thread, sequence }; }); - const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { - yield* SubscriptionRef.set(state, { - data: Option.none(), - status: "deleted", - error: Option.none(), - }); + const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* (sequence?: number) { + // Commit the deletion and advance the cursor together (Fix 2c) so a + // reconnect after an interrupt never resubscribes from a cursor out of step + // with the (now empty) thread state. + yield* Effect.uninterruptible( + Effect.gen(function* () { + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + data: Option.none(), + status: "deleted" as const, + error: Option.none(), + hasOlder: false, + loadingOlder: false, + })); + if (sequence !== undefined) { + yield* SubscriptionRef.set(lastSequence, sequence); + } + }), + ); + // Clear the persistable pair so the scope finalizer cannot resurrect a + // just-deleted thread back into the cache (Fix 2 review). + lastPersistable = null; yield* cache.removeThread(environmentId, threadId).pipe( Effect.catch((error) => Effect.logWarning("Could not remove the cached thread.").pipe( @@ -157,8 +258,23 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make item: OrchestrationThreadStreamItem, ) { if (item.kind === "snapshot") { - yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); - yield* setThread(item.snapshot.thread); + // setThread advances the cursor atomically with the thread (Fix 2c). + yield* setThread(item.snapshot.thread, item.snapshot.snapshotSequence); + return; + } + + if (item.kind === "catchup") { + // Incremental sync (Fix 2): keep the cached thread and apply the `event` + // items that follow. The cursor is already at `fromSequence` (the + // `sinceSequence` we requested); advance it defensively and mark the + // stream live so a zero-delta catch-up clears "synchronizing" without + // re-downloading anything. + yield* SubscriptionRef.update(lastSequence, (seq) => Math.max(seq, item.fromSequence)); + yield* SubscriptionRef.update(state, (current) => + current.status === "deleted" || Option.isNone(current.data) + ? current + : { ...current, status: "live" as const, error: Option.none() }, + ); return; } @@ -166,23 +282,305 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (item.event.sequence <= sequence) { return; } - yield* SubscriptionRef.set(lastSequence, item.event.sequence); const current = yield* SubscriptionRef.get(state); if (Option.isNone(current.data)) { if (item.event.type === "thread.deleted") { - yield* setDeleted(); + yield* setDeleted(item.event.sequence); + } else { + // No thread to apply the event to yet; advance the cursor so we don't + // reprocess it (Fix 2c: the cursor only moves with committed state). + yield* SubscriptionRef.set(lastSequence, item.event.sequence); } return; } + if (isWindowedThread(current.data.value)) { + // A server downgrade cannot safely apply legacy deltas to a partial v2 + // window. The v1 subscribe input omits its cursor, so a snapshot follows. + return; + } const result = applyThreadDetailEvent(current.data.value, item.event); if (result.kind === "updated") { - yield* setThread(result.thread); + yield* setThread(result.thread, item.event.sequence); } else if (result.kind === "deleted") { - yield* setDeleted(); + yield* setDeleted(item.event.sequence); + } else { + // Event left the thread unchanged; advance the cursor past it so a + // reconnect resumes correctly and we never reprocess it. + yield* SubscriptionRef.set(lastSequence, item.event.sequence); + } + }); + + interface SnapshotStaging { + readonly snapshotId: string; + readonly historyEpoch: number; + readonly watermark: number; + readonly chunkCount: number; + nextIndex: number; + head: ThreadHead | null; + messages: ThreadWindowMessage[]; + activities: OrchestrationThreadActivity[]; + } + let staging: SnapshotStaging | null = null; + let pendingCatchup: { remaining: number; readonly toSequence: number } | null = null; + const forceV2Snapshot = yield* Ref.make(false); + // Resync must RESTART the v2 subscription itself. `supervisor.retryNow` is a + // no-op while the connection is healthy, so relying on it alone left the + // stream running with cleared staging — consuming live events forever without + // ever committing a snapshot (an unrecoverable silent hang on-device). + const v2Resync = yield* Queue.unbounded(); + const requestV2Resync = Effect.fn("EnvironmentThreadState.requestV2Resync")(function* ( + reason: string, + ) { + staging = null; + pendingCatchup = null; + yield* Effect.logWarning("Thread sync v2 resync requested.").pipe( + Effect.annotateLogs({ environmentId, threadId, reason }), + ); + yield* Ref.set(forceV2Snapshot, true); + yield* Queue.offer(v2Resync, reason); + }); + const finishV2CatchupEvent = Effect.fn("EnvironmentThreadState.finishV2CatchupEvent")( + function* () { + if (pendingCatchup === null) return; + pendingCatchup.remaining -= 1; + if (pendingCatchup.remaining > 0) return; + const toSequence = pendingCatchup.toSequence; + pendingCatchup = null; + const current = yield* SubscriptionRef.get(state); + if (Option.isNone(current.data) || !isWindowedThread(current.data.value)) return; + const sequence = yield* SubscriptionRef.get(lastSequence); + yield* setThread(current.data.value, Math.max(sequence, toSequence)); + }, + ); + + const applyV2Item = Effect.fn("EnvironmentThreadState.applyV2Item")(function* ( + item: OrchestrationThreadV2StreamItem, + ) { + switch (item.kind) { + case "snapshot-start": + pendingCatchup = null; + staging = { + snapshotId: item.snapshotId, + historyEpoch: item.historyEpoch, + watermark: item.watermark, + chunkCount: item.chunkCount, + nextIndex: 0, + head: null, + messages: [], + activities: [], + }; + return; + case "snapshot-chunk": + if ( + staging === null || + staging.snapshotId !== item.snapshotId || + staging.nextIndex !== item.index + ) { + yield* requestV2Resync( + staging === null + ? "chunk-without-start" + : staging.snapshotId !== item.snapshotId + ? "chunk-snapshot-id-mismatch" + : `chunk-order (expected ${staging.nextIndex}, got ${item.index})`, + ); + return; + } + if (item.head !== undefined) { + if (staging.head !== null) { + yield* requestV2Resync("duplicate-head-chunk"); + return; + } + staging.head = item.head; + } + staging.messages.push(...item.messages); + staging.activities.push(...item.activities); + staging.nextIndex += 1; + return; + case "snapshot-complete": { + const completed = staging; + staging = null; + if ( + completed === null || + completed.snapshotId !== item.snapshotId || + completed.nextIndex !== completed.chunkCount || + completed.head === null || + completed.historyEpoch !== item.historyEpoch || + completed.watermark !== item.lastAppliedSequence + ) { + yield* requestV2Resync( + completed === null + ? "complete-without-start" + : completed.snapshotId !== item.snapshotId + ? "complete-snapshot-id-mismatch" + : completed.nextIndex !== completed.chunkCount + ? `chunk-count (advertised ${completed.chunkCount}, received ${completed.nextIndex})` + : completed.head === null + ? "missing-head-chunk" + : completed.historyEpoch !== item.historyEpoch + ? "complete-epoch-mismatch" + : `watermark (start ${completed.watermark}, complete ${item.lastAppliedSequence})`, + ); + return; + } + const window: WindowedOrchestrationThread = { + syncVersion: 2, + historyEpoch: item.historyEpoch, + lastAppliedSequence: item.lastAppliedSequence, + head: completed.head, + messages: completed.messages, + activities: completed.activities, + before: item.before, + hasOlderMessages: item.hasOlderMessages, + hasOlderActivities: item.hasOlderActivities, + }; + yield* setThread(fromWindowSnapshot(window), item.lastAppliedSequence); + yield* Ref.set(forceV2Snapshot, false); + return; + } + case "catchup": { + const current = yield* SubscriptionRef.get(state); + const sequence = yield* SubscriptionRef.get(lastSequence); + if ( + Option.isNone(current.data) || + !isWindowedThread(current.data.value) || + current.data.value.historyEpoch !== item.historyEpoch || + sequence !== item.fromSequence + ) { + yield* requestV2Resync( + Option.isNone(current.data) + ? "catchup-without-data" + : !isWindowedThread(current.data.value) + ? "catchup-on-legacy-thread" + : current.data.value.historyEpoch !== item.historyEpoch + ? "catchup-epoch-mismatch" + : `catchup-cursor (local ${sequence}, server ${item.fromSequence})`, + ); + return; + } + if (item.eventCount === 0) { + yield* setThread(current.data.value, item.toSequence); + return; + } + pendingCatchup = { remaining: item.eventCount, toSequence: item.toSequence }; + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + status: "live" as const, + error: Option.none(), + })); + return; + } + case "event": { + const sequence = yield* SubscriptionRef.get(lastSequence); + if (item.event.sequence <= sequence) { + yield* finishV2CatchupEvent(); + return; + } + const current = yield* SubscriptionRef.get(state); + if (Option.isNone(current.data)) { + if (item.event.type === "thread.deleted") { + yield* setDeleted(item.event.sequence); + } else { + yield* requestV2Resync("event-without-data"); + } + return; + } + if (!isWindowedThread(current.data.value)) { + yield* requestV2Resync("event-on-legacy-thread"); + return; + } + const result = applyWindowedThreadEvent(current.data.value, item.event); + switch (result.kind) { + case "updated": + yield* setThread(result.thread, item.event.sequence); + yield* finishV2CatchupEvent(); + return; + case "deleted": + pendingCatchup = null; + yield* setDeleted(item.event.sequence); + return; + case "resync": + yield* requestV2Resync(`event-reducer-resync (${item.event.type})`); + return; + case "unchanged": + yield* setThread(current.data.value, item.event.sequence); + yield* finishV2CatchupEvent(); + return; + } + } + case "resync-required": + yield* requestV2Resync(`server-resync-required (${item.reason})`); + return; + case "keepalive": + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + status: current.status === "deleted" ? current.status : ("live" as const), + error: Option.none(), + })); + return; } }); + const olderRequestInFlight = yield* Ref.make(false); + const loadOlderEffect = Effect.gen(function* () { + const acquired = yield* Ref.modify(olderRequestInFlight, (active) => + active ? [false, active] : [true, true], + ); + if (!acquired) return; + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + loadingOlder: true, + })); + yield* Effect.gen(function* () { + const current = yield* SubscriptionRef.get(state); + if (Option.isNone(current.data) || !isWindowedThread(current.data.value)) return; + const thread = current.data.value; + if (!thread.hasOlderMessages && !thread.hasOlderActivities) return; + const session = yield* SubscriptionRef.get(supervisor.session); + if (Option.isNone(session)) return; + const page = yield* session.value.client[ORCHESTRATION_WS_METHODS.getThreadHistoryPage]({ + threadId, + historyEpoch: thread.historyEpoch, + before: thread.before, + messageLimit: 32, + activityLimit: 128, + }); + // Re-read the thread AFTER the RPC: live events (especially a revert, + // which changes the history epoch) may have advanced it while the page + // was in flight, and committing a merge of the stale pre-request window + // would roll the thread back. Merge into the CURRENT window and drop the + // page entirely if the epoch moved (the resync already reloaded the tail). + const latest = yield* SubscriptionRef.get(state); + if (Option.isNone(latest.data) || !isWindowedThread(latest.data.value)) return; + const latestThread = latest.data.value; + if (latestThread.historyEpoch !== thread.historyEpoch) return; + const next: WindowedOrchestrationThreadState = mergeWindowHistoryPage(latestThread, page); + yield* setThread(next, latestThread.lastAppliedSequence); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not load older thread history.", { + environmentId, + threadId, + cause, + }).pipe(Effect.andThen(requestV2Resync("history-page-failed"))), + ), + Effect.ensuring( + Effect.all( + [ + Ref.set(olderRequestInFlight, false), + SubscriptionRef.update(state, (current) => ({ + ...current, + loadingOlder: false, + })), + ], + { discard: true }, + ), + ), + ); + }); + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + loadOlderImpl = () => runPromise(loadOlderEffect); + yield* SubscriptionRef.changes(supervisor.state).pipe( Stream.runForEach((connectionState) => { switch (connectionProjectionPhase(connectionState)) { @@ -197,59 +595,105 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Effect.forkScoped, ); + const resolveV2SubscribeInput = Effect.gen(function* () { + const seq = yield* SubscriptionRef.get(lastSequence); + const current = yield* SubscriptionRef.get(state); + const forceSnapshot = yield* Ref.get(forceV2Snapshot); + return !forceSnapshot && + seq > 0 && + Option.isSome(current.data) && + isWindowedThread(current.data.value) + ? { + threadId, + sinceSequence: seq, + historyEpoch: current.data.value.historyEpoch, + } + : { threadId }; + }); + yield* setSynchronizing; - yield* Effect.forkScoped( - Effect.gen(function* () { - // Establish the base snapshot to resume from, minimizing bytes over the - // wire: - // - Warm cache: reuse the cached snapshot (zero network) and resume via - // `afterSequence` so we only receive events since the cached sequence. - // - Cold cache: load the full snapshot over HTTP (gzip-compressible, and - // off the socket), then resume via `afterSequence`. - // If no base can be established we fall back to the socket-embedded - // snapshot so the thread still synchronizes. Overlapping/replayed events - // are deduped by sequence in applyItem. - const base = Option.isSome(cached) - ? cached - : yield* Effect.gen(function* () { - // Cold cache only: wait for a prepared connection so we can - // authenticate the HTTP request; this mirrors the socket path, which - // likewise waits for a live session. - const prepared = yield* SubscriptionRef.changes(supervisor.prepared).pipe( - Stream.filter(Option.isSome), - Stream.map((current) => current.value), - Stream.runHead, - ); - return Option.isSome(prepared) - ? yield* snapshotLoader.load(prepared.value, threadId) - : Option.none(); - }); + // Legacy (v1) flow — upstream's HTTP-snapshot + afterSequence resume, + // activated per prepared connection when v2 is not negotiated: + // - Warm cache: reuse the cached snapshot (zero network) and resume via + // `afterSequence` so we only receive events since it. + // - Cold cache: load the full snapshot over HTTP (gzip-compressible, and + // off the socket), then resume via `afterSequence`. + // A windowed (v2) cache cannot seed the legacy reducer, so it is treated as + // cold here; the on-screen cached window remains until the full snapshot + // arrives. Overlapping/replayed events are deduped by sequence in applyItem. + const legacyFlow = (prepared: PreparedConnection) => + Stream.unwrap( + Effect.gen(function* () { + const legacyCached = + Option.isSome(cached) && !isWindowSnapshot(cached.value) + ? Option.some(cached.value) + : Option.none(); + const base = Option.isSome(legacyCached) + ? legacyCached + : yield* snapshotLoader.load(prepared, threadId); - if (Option.isSome(base)) { - yield* applyItem({ kind: "snapshot", snapshot: base.value }); - } + if (Option.isSome(base)) { + yield* applyItem({ kind: "snapshot", snapshot: base.value }); + } - const subscribeInput = Option.match(base, { - onNone: () => ({ threadId }), - onSome: (snapshot) => ({ threadId, afterSequence: snapshot.snapshotSequence }), - }); + const subscribeInput = Option.match(base, { + onNone: () => ({ threadId }), + onSome: (snapshot) => ({ threadId, afterSequence: snapshot.snapshotSequence }), + }); - yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeThread, subscribeInput, { - onExpectedFailure: setStreamError, - retryExpectedFailureAfter: "250 millis", - }).pipe(Stream.runForEach(applyItem)); - }), - ); + return subscribe(ORCHESTRATION_WS_METHODS.subscribeThread, subscribeInput, { + onExpectedFailure: setStreamError, + retryExpectedFailureAfter: "250 millis", + }); + }), + ); - yield* Effect.addFinalizer(() => - Effect.all([SubscriptionRef.get(state), SubscriptionRef.get(lastSequence)]).pipe( - Effect.flatMap(([current, snapshotSequence]) => - Option.match(current.data, { - onNone: () => Effect.void, - onSome: (thread) => persist({ snapshotSequence, thread }), - }), + // ONE pipeline owns the thread. A single switchMap over negotiation changes + // (merged with v2 resync signals, which re-read `prepared` and — with + // forceV2Snapshot set — request a fresh tail) selects EITHER the v2 or the + // legacy subscription, so a v1<->v2 transition atomically cancels one and + // starts the other; two independently forked pipelines could briefly overlap + // or both go dark during the handoff. + type SyncItem = + | { readonly v: 2; readonly item: OrchestrationThreadV2StreamItem } + | { readonly v: 1; readonly item: OrchestrationThreadStreamItem }; + const syncTriggers = Stream.merge( + SubscriptionRef.changes(supervisor.prepared), + Stream.fromQueue(v2Resync).pipe( + Stream.mapEffect(() => SubscriptionRef.get(supervisor.prepared)), + ), + ); + yield* syncTriggers.pipe( + Stream.switchMap( + Option.match({ + onNone: () => Stream.empty as Stream.Stream, + onSome: (connection) => + connection.threadSyncVersion === 2 + ? subscribe(ORCHESTRATION_WS_METHODS.subscribeThreadV2, resolveV2SubscribeInput, { + onExpectedFailure: setStreamError, + retryExpectedFailureAfter: "250 millis", + }).pipe(Stream.map((item): SyncItem => ({ v: 2, item }))) + : legacyFlow(connection).pipe(Stream.map((item): SyncItem => ({ v: 1, item }))), + }), + ), + Stream.runForEach((entry) => (entry.v === 2 ? applyV2Item(entry.item) : applyItem(entry.item))), + // Surface silent fiber deaths (defects kill the forked pipeline without any + // user-visible signal otherwise — the thread just never loads). + Effect.tapCause((cause) => + Effect.logError("Thread sync pipeline failed.").pipe( + Effect.annotateLogs({ environmentId, threadId, cause: Cause.pretty(cause) }), ), ), + Effect.forkScoped, + ); + + // Persist the last CONSISTENT + // Persist the last CONSISTENT {thread, sequence} pair on teardown (Fix 2). + // Deliberately does NOT read `state` + `lastSequence` separately: those two + // refs can be momentarily torn by an interrupted apply, and persisting a cursor + // ahead of its thread would make reconnect deltas skip the gap permanently. + yield* Effect.addFinalizer(() => + lastPersistable === null ? Effect.void : persist(lastPersistable), ); return state; @@ -295,3 +739,4 @@ export * from "./threadDetail.ts"; export * from "./threadReducer.ts"; export * from "./threadShell.ts"; export * from "./threadState.ts"; +export * from "./windowedThread.ts"; diff --git a/packages/client-runtime/src/state/windowedThread.test.ts b/packages/client-runtime/src/state/windowedThread.test.ts new file mode 100644 index 00000000000..da599f70690 --- /dev/null +++ b/packages/client-runtime/src/state/windowedThread.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, + type WindowedOrchestrationThread, +} from "@t3tools/contracts"; + +import { + applyWindowedThreadEvent, + fromWindowSnapshot, + mergeWindowHistoryPage, +} from "./windowedThread.ts"; + +const threadId = ThreadId.make("thread-1"); +const messageId = MessageId.make("message-1"); +const snapshot: WindowedOrchestrationThread = { + syncVersion: 2, + historyEpoch: 0, + lastAppliedSequence: 10, + head: { + id: threadId, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + session: null, + activeProposedPlan: null, + pendingRequests: [], + counts: { messages: 2, activities: 0 }, + }, + messages: [ + { + id: messageId, + role: "user", + text: "boundary", + turnId: null, + streaming: false, + createdAt: "2026-01-02T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + ], + activities: [], + before: { + message: { createdAt: "2026-01-02T00:00:00.000Z", messageId }, + activity: null, + }, + hasOlderMessages: true, + hasOlderActivities: false, +}; + +describe("windowed thread", () => { + it("deduplicates the page boundary by id", () => { + const thread = fromWindowSnapshot(snapshot); + const merged = mergeWindowHistoryPage(thread, { + historyEpoch: 0, + messages: [ + { + ...snapshot.messages[0]!, + text: "boundary replacement", + }, + ], + activities: [], + before: snapshot.before, + hasOlderMessages: false, + hasOlderActivities: false, + }); + expect(merged.messages).toHaveLength(1); + expect(merged.messages[0]?.text).toBe("boundary replacement"); + }); + + it("requests a tail resync on revert", () => { + const event: OrchestrationEvent = { + sequence: 11, + eventId: EventId.make("event-1"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-03T00:00:00.000Z", + commandId: CommandId.make("command-1"), + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.reverted", + payload: { threadId, turnCount: 0 }, + }; + expect(applyWindowedThreadEvent(fromWindowSnapshot(snapshot), event).kind).toBe("resync"); + }); +}); diff --git a/packages/client-runtime/src/state/windowedThread.ts b/packages/client-runtime/src/state/windowedThread.ts new file mode 100644 index 00000000000..c3a8c753727 --- /dev/null +++ b/packages/client-runtime/src/state/windowedThread.ts @@ -0,0 +1,433 @@ +import type { + OrchestrationCheckpointSummary, + OrchestrationEvent, + OrchestrationGetThreadHistoryPageResult, + OrchestrationLatestTurn, + OrchestrationMessage, + OrchestrationProposedPlan, + OrchestrationSession, + OrchestrationThread, + OrchestrationThreadActivity, + ThreadHistoryCursor, + WindowedOrchestrationThread, +} from "@t3tools/contracts"; + +export interface WindowedOrchestrationThreadState { + readonly syncVersion: 2; + readonly id: WindowedOrchestrationThread["head"]["id"]; + readonly projectId: WindowedOrchestrationThread["head"]["projectId"]; + readonly title: string; + readonly modelSelection: WindowedOrchestrationThread["head"]["modelSelection"]; + readonly runtimeMode: WindowedOrchestrationThread["head"]["runtimeMode"]; + readonly interactionMode: WindowedOrchestrationThread["head"]["interactionMode"]; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly latestTurn: OrchestrationLatestTurn | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly archivedAt: string | null; + readonly deletedAt: string | null; + readonly messages: ReadonlyArray; + readonly activities: ReadonlyArray; + readonly proposedPlans: ReadonlyArray; + readonly checkpoints: ReadonlyArray; + readonly session: OrchestrationSession | null; + readonly historyEpoch: number; + readonly lastAppliedSequence: number; + readonly before: ThreadHistoryCursor; + readonly hasOlderMessages: boolean; + readonly hasOlderActivities: boolean; + readonly messageCount: number; + readonly activityCount: number; + readonly pendingRequests: ReadonlyArray; +} + +export type ThreadDetailData = OrchestrationThread | WindowedOrchestrationThreadState; + +export function isWindowedThread( + thread: ThreadDetailData, +): thread is WindowedOrchestrationThreadState { + return "syncVersion" in thread && thread.syncVersion === 2; +} + +function mergeActivities( + activities: ReadonlyArray, + pendingRequests: ReadonlyArray, +): OrchestrationThreadActivity[] { + const byId = new Map(activities.map((activity) => [activity.id, activity])); + for (const pending of pendingRequests) byId.set(pending.id, pending); + return [...byId.values()].sort( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ); +} + +export function fromWindowSnapshot( + window: WindowedOrchestrationThread, +): WindowedOrchestrationThreadState { + const head = window.head; + return { + syncVersion: 2, + id: head.id, + projectId: head.projectId, + title: head.title, + modelSelection: head.modelSelection, + runtimeMode: head.runtimeMode, + interactionMode: head.interactionMode, + branch: head.branch, + worktreePath: head.worktreePath, + latestTurn: head.latestTurn, + createdAt: head.createdAt, + updatedAt: head.updatedAt, + archivedAt: head.archivedAt, + deletedAt: head.deletedAt, + messages: window.messages, + activities: mergeActivities(window.activities, head.pendingRequests), + proposedPlans: head.activeProposedPlan === null ? [] : [head.activeProposedPlan], + checkpoints: [], + session: head.session, + historyEpoch: window.historyEpoch, + lastAppliedSequence: window.lastAppliedSequence, + before: window.before, + hasOlderMessages: window.hasOlderMessages, + hasOlderActivities: window.hasOlderActivities, + messageCount: head.counts.messages, + activityCount: head.counts.activities, + pendingRequests: head.pendingRequests, + }; +} + +export function toWindowSnapshot( + thread: WindowedOrchestrationThreadState, +): WindowedOrchestrationThread { + const pendingIds = new Set(thread.pendingRequests.map((activity) => activity.id)); + return { + syncVersion: 2, + historyEpoch: thread.historyEpoch, + lastAppliedSequence: thread.lastAppliedSequence, + head: { + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestTurn: thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + deletedAt: thread.deletedAt, + session: thread.session, + activeProposedPlan: thread.proposedPlans.at(-1) ?? null, + pendingRequests: thread.pendingRequests, + counts: { + messages: thread.messageCount, + activities: thread.activityCount, + }, + }, + messages: thread.messages, + activities: thread.activities.filter((activity) => !pendingIds.has(activity.id)), + before: thread.before, + hasOlderMessages: thread.hasOlderMessages, + hasOlderActivities: thread.hasOlderActivities, + }; +} + +export function mergeWindowHistoryPage( + thread: WindowedOrchestrationThreadState, + page: OrchestrationGetThreadHistoryPageResult, +): WindowedOrchestrationThreadState { + if (page.historyEpoch !== thread.historyEpoch) return thread; + const messageById = new Map(thread.messages.map((message) => [message.id, message])); + for (const message of page.messages) messageById.set(message.id, message); + const activityById = new Map(thread.activities.map((activity) => [activity.id, activity])); + for (const activity of page.activities) activityById.set(activity.id, activity); + return { + ...thread, + messages: [...messageById.values()].sort( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ), + activities: [...activityById.values()].sort( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ), + before: page.before, + hasOlderMessages: page.hasOlderMessages, + hasOlderActivities: page.hasOlderActivities, + }; +} + +export type WindowedThreadReducerResult = + | { + readonly kind: "updated"; + readonly thread: WindowedOrchestrationThreadState; + } + | { readonly kind: "deleted" } + | { readonly kind: "resync" } + | { readonly kind: "unchanged" }; + +function upsertMessage( + messages: ReadonlyArray, + message: OrchestrationMessage, +): { + readonly messages: ReadonlyArray; + readonly inserted: boolean; +} { + const existing = messages.find((entry) => entry.id === message.id); + if (existing === undefined) return { messages: [...messages, message], inserted: true }; + return { + inserted: false, + messages: messages.map((entry) => + entry.id !== message.id + ? entry + : { + ...entry, + text: message.streaming + ? `${entry.text}${message.text}` + : message.text.length > 0 + ? message.text + : entry.text, + streaming: message.streaming, + updatedAt: message.streaming ? entry.updatedAt : message.updatedAt, + ...(message.attachments === undefined ? {} : { attachments: message.attachments }), + }, + ), + }; +} + +export function applyWindowedThreadEvent( + thread: WindowedOrchestrationThreadState, + event: OrchestrationEvent, +): WindowedThreadReducerResult { + switch (event.type) { + case "thread.deleted": + return { kind: "deleted" }; + case "thread.reverted": + return { kind: "resync" }; + case "thread.archived": + return { + kind: "updated", + thread: { + ...thread, + archivedAt: event.payload.archivedAt, + updatedAt: event.payload.updatedAt, + }, + }; + case "thread.unarchived": + return { + kind: "updated", + thread: { + ...thread, + archivedAt: null, + updatedAt: event.payload.updatedAt, + }, + }; + case "thread.meta-updated": + return { + kind: "updated", + thread: { + ...thread, + ...(event.payload.title === undefined ? {} : { title: event.payload.title }), + ...(event.payload.modelSelection === undefined + ? {} + : { modelSelection: event.payload.modelSelection }), + ...(event.payload.branch === undefined ? {} : { branch: event.payload.branch }), + ...(event.payload.worktreePath === undefined + ? {} + : { worktreePath: event.payload.worktreePath }), + updatedAt: event.payload.updatedAt, + }, + }; + case "thread.runtime-mode-set": + return { + kind: "updated", + thread: { + ...thread, + runtimeMode: event.payload.runtimeMode, + updatedAt: event.payload.updatedAt, + }, + }; + case "thread.interaction-mode-set": + return { + kind: "updated", + thread: { + ...thread, + interactionMode: event.payload.interactionMode, + updatedAt: event.payload.updatedAt, + }, + }; + case "thread.turn-start-requested": + return { + kind: "updated", + thread: { + ...thread, + ...(event.payload.modelSelection === undefined + ? {} + : { modelSelection: event.payload.modelSelection }), + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, + updatedAt: event.occurredAt, + }, + }; + case "thread.turn-interrupt-requested": + return event.payload.turnId === undefined || + thread.latestTurn?.turnId !== event.payload.turnId + ? { kind: "unchanged" } + : { + kind: "updated", + thread: { + ...thread, + latestTurn: { + ...thread.latestTurn, + state: "interrupted", + startedAt: thread.latestTurn.startedAt ?? event.payload.createdAt, + completedAt: thread.latestTurn.completedAt ?? event.payload.createdAt, + }, + updatedAt: event.occurredAt, + }, + }; + case "thread.message-sent": { + const next = upsertMessage(thread.messages, { + id: event.payload.messageId, + role: event.payload.role, + text: event.payload.text, + ...(event.payload.attachments === undefined + ? {} + : { attachments: event.payload.attachments }), + turnId: event.payload.turnId, + streaming: event.payload.streaming, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + }); + const turnStillRunning = + event.payload.turnId !== null && + thread.session?.status === "running" && + thread.session.activeTurnId === event.payload.turnId; + const settlesTurn = !event.payload.streaming && !turnStillRunning; + const latestTurn = + event.payload.role === "assistant" && + event.payload.turnId !== null && + (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) + ? { + turnId: event.payload.turnId, + state: settlesTurn + ? thread.latestTurn?.state === "interrupted" + ? ("interrupted" as const) + : thread.latestTurn?.state === "error" + ? ("error" as const) + : ("completed" as const) + : ("running" as const), + requestedAt: + thread.latestTurn?.turnId === event.payload.turnId + ? thread.latestTurn.requestedAt + : event.payload.createdAt, + startedAt: + thread.latestTurn?.turnId === event.payload.turnId + ? (thread.latestTurn.startedAt ?? event.payload.createdAt) + : event.payload.createdAt, + completedAt: settlesTurn ? event.payload.updatedAt : null, + assistantMessageId: event.payload.messageId, + } + : thread.latestTurn; + return { + kind: "updated", + thread: { + ...thread, + messages: next.messages, + messageCount: thread.messageCount + (next.inserted ? 1 : 0), + latestTurn, + updatedAt: event.occurredAt, + }, + }; + } + case "thread.activity-appended": { + const inserted = !thread.activities.some( + (activity) => activity.id === event.payload.activity.id, + ); + const activities = mergeActivities( + thread.activities.filter((activity) => activity.id !== event.payload.activity.id), + [event.payload.activity], + ); + return { + kind: "updated", + thread: { + ...thread, + activities, + activityCount: thread.activityCount + (inserted ? 1 : 0), + updatedAt: event.occurredAt, + }, + }; + } + case "thread.session-set": { + const session = event.payload.session; + const latestTurn = + session.status === "running" && session.activeTurnId !== null + ? { + turnId: session.activeTurnId, + state: "running" as const, + requestedAt: + thread.latestTurn?.turnId === session.activeTurnId + ? thread.latestTurn.requestedAt + : session.updatedAt, + startedAt: + thread.latestTurn?.turnId === session.activeTurnId + ? (thread.latestTurn.startedAt ?? session.updatedAt) + : session.updatedAt, + completedAt: null, + assistantMessageId: + thread.latestTurn?.turnId === session.activeTurnId + ? thread.latestTurn.assistantMessageId + : null, + } + : thread.latestTurn?.state === "running" + ? { + ...thread.latestTurn, + state: session.status === "error" ? ("error" as const) : ("completed" as const), + completedAt: session.updatedAt, + } + : thread.latestTurn; + return { + kind: "updated", + thread: { ...thread, session, latestTurn, updatedAt: event.occurredAt }, + }; + } + case "thread.session-stop-requested": + return thread.session === null + ? { kind: "unchanged" } + : { + kind: "updated", + thread: { + ...thread, + session: { + ...thread.session, + status: "stopped", + activeTurnId: null, + updatedAt: event.payload.createdAt, + }, + updatedAt: event.occurredAt, + }, + }; + case "thread.proposed-plan-upserted": + return { + kind: "updated", + thread: { + ...thread, + proposedPlans: [event.payload.proposedPlan], + updatedAt: event.occurredAt, + }, + }; + case "project.created": + case "project.meta-updated": + case "project.deleted": + case "thread.created": + case "thread.turn-diff-completed": + case "thread.approval-response-requested": + case "thread.user-input-response-requested": + case "thread.checkpoint-revert-requested": + return { kind: "unchanged" }; + } + return { kind: "unchanged" }; +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index fb52972d5aa..6d5ac6d63cd 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -25,12 +25,24 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; +export const ExecutionEnvironmentRpcTransport = Schema.Struct({ + kind: Schema.Literals(["json", "gzip-json"]), + path: TrimmedNonEmptyString, +}); +export type ExecutionEnvironmentRpcTransport = typeof ExecutionEnvironmentRpcTransport.Type; + export const ExecutionEnvironmentDescriptor = Schema.Struct({ environmentId: EnvironmentId, label: TrimmedNonEmptyString, platform: ExecutionEnvironmentPlatform, serverVersion: TrimmedNonEmptyString, capabilities: ExecutionEnvironmentCapabilities, + rpcTransports: Schema.optional(Schema.Array(ExecutionEnvironmentRpcTransport)).pipe( + Schema.withDecodingDefault(Effect.succeed([{ kind: "json" as const, path: "/ws" }])), + ), + threadSyncVersions: Schema.optional(Schema.Array(Schema.Literals([1, 2]))).pipe( + Schema.withDecodingDefault(Effect.succeed([1 as const])), + ), }); export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 43270efdec7..03c980d8620 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -26,3 +26,4 @@ export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./rpc.ts"; +export * from "./rpcCompression.ts"; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 7e9c421b5df..9d725e308d9 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -26,10 +26,12 @@ export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", + getThreadHistoryPage: "orchestration.getThreadHistoryPage", replayEvents: "orchestration.replayEvents", getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", subscribeShell: "orchestration.subscribeShell", subscribeThread: "orchestration.subscribeThread", + subscribeThreadV2: "orchestration.subscribeThreadV2", } as const; export const ProviderApprovalPolicy = Schema.Literals([ @@ -367,6 +369,86 @@ export const OrchestrationThread = Schema.Struct({ }); export type OrchestrationThread = typeof OrchestrationThread.Type; +export const ThreadWindowMessage = Schema.Struct({ + ...OrchestrationMessage.fields, + textTruncated: Schema.optional(Schema.Boolean), + originalTextBytes: Schema.optional(NonNegativeInt), +}); +export type ThreadWindowMessage = typeof ThreadWindowMessage.Type; + +export const ThreadMessageHistoryCursor = Schema.Struct({ + createdAt: IsoDateTime, + messageId: MessageId, +}); +export type ThreadMessageHistoryCursor = typeof ThreadMessageHistoryCursor.Type; + +export const ThreadActivityHistoryCursor = Schema.Struct({ + createdAt: IsoDateTime, + activityId: EventId, +}); +export type ThreadActivityHistoryCursor = typeof ThreadActivityHistoryCursor.Type; + +export const ThreadHistoryCursor = Schema.Struct({ + message: Schema.NullOr(ThreadMessageHistoryCursor), + activity: Schema.NullOr(ThreadActivityHistoryCursor), +}); +export type ThreadHistoryCursor = typeof ThreadHistoryCursor.Type; + +export const ThreadHeadCounts = Schema.Struct({ + messages: NonNegativeInt, + activities: NonNegativeInt, +}); +export type ThreadHeadCounts = typeof ThreadHeadCounts.Type; + +/** Metadata and actionable state that must arrive before any history body. */ +export const ThreadHead = Schema.Struct({ + id: ThreadId, + projectId: ProjectId, + title: TrimmedNonEmptyString, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + latestTurn: Schema.NullOr(OrchestrationLatestTurn), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime), + deletedAt: Schema.NullOr(IsoDateTime), + session: Schema.NullOr(OrchestrationSession), + activeProposedPlan: Schema.NullOr(OrchestrationProposedPlan), + pendingRequests: Schema.Array(OrchestrationThreadActivity), + counts: ThreadHeadCounts, +}); +export type ThreadHead = typeof ThreadHead.Type; + +/** + * Client/cache representation of a bounded v2 history window. It is deliberately + * distinct from `OrchestrationThread`; the legacy reducer must never receive it. + */ +export const WindowedOrchestrationThread = Schema.Struct({ + syncVersion: Schema.Literal(2), + historyEpoch: NonNegativeInt, + lastAppliedSequence: NonNegativeInt, + head: ThreadHead, + messages: Schema.Array(ThreadWindowMessage), + activities: Schema.Array(OrchestrationThreadActivity), + before: ThreadHistoryCursor, + hasOlderMessages: Schema.Boolean, + hasOlderActivities: Schema.Boolean, +}); +export type WindowedOrchestrationThread = typeof WindowedOrchestrationThread.Type; + +export const ActivityPayloadAssetReference = Schema.TaggedStruct("activity-payload-asset", { + resource: Schema.TaggedStruct("attachment", { + attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + }), + mediaType: TrimmedNonEmptyString, + byteLength: NonNegativeInt, + sha256: TrimmedNonEmptyString, +}); +export type ActivityPayloadAssetReference = typeof ActivityPayloadAssetReference.Type; + export const OrchestrationReadModel = Schema.Struct({ snapshotSequence: NonNegativeInt, projects: Schema.Array(OrchestrationProject), @@ -1139,6 +1221,15 @@ export const OrchestrationThreadStreamItem = Schema.Union([ kind: Schema.Literal("snapshot"), snapshot: OrchestrationThreadDetailSnapshot, }), + // Incremental sync (Fix 2): tells the client to KEEP its cached thread and + // apply the `event` items that follow (sequence in `(fromSequence, toSequence]`) + // rather than replacing it with a full snapshot. `fromSequence` echoes the + // client's requested `sinceSequence`; `toSequence` is the server's watermark. + Schema.Struct({ + kind: Schema.Literal("catchup"), + fromSequence: NonNegativeInt, + toSequence: NonNegativeInt, + }), Schema.Struct({ kind: Schema.Literal("event"), event: OrchestrationEvent, @@ -1146,6 +1237,82 @@ export const OrchestrationThreadStreamItem = Schema.Union([ ]); export type OrchestrationThreadStreamItem = typeof OrchestrationThreadStreamItem.Type; +export const OrchestrationSubscribeThreadV2Input = Schema.Struct({ + threadId: ThreadId, + sinceSequence: Schema.optional(NonNegativeInt), + historyEpoch: Schema.optional(NonNegativeInt), +}); +export type OrchestrationSubscribeThreadV2Input = typeof OrchestrationSubscribeThreadV2Input.Type; + +export const OrchestrationThreadV2StreamItem = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("snapshot-start"), + snapshotId: TrimmedNonEmptyString, + historyEpoch: NonNegativeInt, + watermark: NonNegativeInt, + chunkCount: NonNegativeInt, + inlineBytes: NonNegativeInt, + }), + Schema.Struct({ + kind: Schema.Literal("snapshot-chunk"), + snapshotId: TrimmedNonEmptyString, + index: NonNegativeInt, + head: Schema.optional(ThreadHead), + messages: Schema.Array(ThreadWindowMessage), + activities: Schema.Array(OrchestrationThreadActivity), + }), + Schema.Struct({ + kind: Schema.Literal("snapshot-complete"), + snapshotId: TrimmedNonEmptyString, + historyEpoch: NonNegativeInt, + lastAppliedSequence: NonNegativeInt, + before: ThreadHistoryCursor, + hasOlderMessages: Schema.Boolean, + hasOlderActivities: Schema.Boolean, + }), + Schema.Struct({ + kind: Schema.Literal("catchup"), + historyEpoch: NonNegativeInt, + fromSequence: NonNegativeInt, + toSequence: NonNegativeInt, + eventCount: NonNegativeInt, + }), + Schema.Struct({ + kind: Schema.Literal("event"), + event: OrchestrationEvent, + }), + Schema.Struct({ + kind: Schema.Literal("keepalive"), + sequence: NonNegativeInt, + }), + Schema.Struct({ + kind: Schema.Literal("resync-required"), + reason: Schema.Literals(["live-buffer-overflow", "history-invalidated"]), + }), +]); +export type OrchestrationThreadV2StreamItem = typeof OrchestrationThreadV2StreamItem.Type; + +export const OrchestrationGetThreadHistoryPageInput = Schema.Struct({ + threadId: ThreadId, + historyEpoch: NonNegativeInt, + before: ThreadHistoryCursor, + messageLimit: NonNegativeInt, + activityLimit: NonNegativeInt, +}); +export type OrchestrationGetThreadHistoryPageInput = + typeof OrchestrationGetThreadHistoryPageInput.Type; + +export const OrchestrationGetThreadHistoryPageResult = Schema.Struct({ + historyEpoch: NonNegativeInt, + messages: Schema.Array(ThreadWindowMessage), + activities: Schema.Array(OrchestrationThreadActivity), + before: ThreadHistoryCursor, + hasOlderMessages: Schema.Boolean, + hasOlderActivities: Schema.Boolean, +}); +export type OrchestrationGetThreadHistoryPageResult = + typeof OrchestrationGetThreadHistoryPageResult.Type; + export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]); export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type; @@ -1253,6 +1420,10 @@ export const OrchestrationRpcSchemas = { input: OrchestrationGetFullThreadDiffInput, output: OrchestrationGetFullThreadDiffResult, }, + getThreadHistoryPage: { + input: OrchestrationGetThreadHistoryPageInput, + output: OrchestrationGetThreadHistoryPageResult, + }, replayEvents: { input: OrchestrationReplayEventsInput, output: OrchestrationReplayEventsResult, @@ -1265,6 +1436,10 @@ export const OrchestrationRpcSchemas = { input: OrchestrationSubscribeThreadInput, output: OrchestrationThreadStreamItem, }, + subscribeThreadV2: { + input: OrchestrationSubscribeThreadV2Input, + output: OrchestrationThreadV2StreamItem, + }, subscribeShell: { input: OrchestrationSubscribeShellInput, output: OrchestrationShellStreamItem, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 48c5d9a774d..a33ad67e144 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -614,6 +614,15 @@ export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( }, ); +export const WsOrchestrationGetThreadHistoryPageRpc = Rpc.make( + ORCHESTRATION_WS_METHODS.getThreadHistoryPage, + { + payload: OrchestrationRpcSchemas.getThreadHistoryPage.input, + success: OrchestrationRpcSchemas.getThreadHistoryPage.output, + error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), + }, +); + export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, { payload: OrchestrationReplayEventsInput, success: OrchestrationRpcSchemas.replayEvents.output, @@ -646,6 +655,16 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make( }, ); +export const WsOrchestrationSubscribeThreadV2Rpc = Rpc.make( + ORCHESTRATION_WS_METHODS.subscribeThreadV2, + { + payload: OrchestrationRpcSchemas.subscribeThreadV2.input, + success: OrchestrationRpcSchemas.subscribeThreadV2.output, + error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), + stream: true, + }, +); + export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { payload: Schema.Struct({}), success: TerminalEvent, @@ -746,8 +765,10 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, + WsOrchestrationGetThreadHistoryPageRpc, WsOrchestrationReplayEventsRpc, WsOrchestrationGetArchivedShellSnapshotRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, + WsOrchestrationSubscribeThreadV2Rpc, ); diff --git a/packages/contracts/src/rpcCompression.ts b/packages/contracts/src/rpcCompression.ts new file mode 100644 index 00000000000..fb53417f3ba --- /dev/null +++ b/packages/contracts/src/rpcCompression.ts @@ -0,0 +1,113 @@ +/** + * Serialization-layer gzip compression for the T3 Code WebSocket RPC. + * + * Wraps (does not fork) effect's `json` `RpcSerialization` in a symmetric codec + * that emits a **binary** WS frame = `1 prefix byte + body`: + * - `0x00` = RAW (utf8 JSON, uncompressed) — used below the size threshold + * - `0x01` = GZIP (gzip of utf8 JSON) — used at/above the threshold + * - `0x02` reserved for a future zstd/msgpack swap + * + * Both ends run identical code so the wire format stays symmetric. Framing is + * left to the transport (`includesFraming: false`, mirroring `json`) because WS + * preserves message boundaries and each `encode`/`decode` is one-message-per- + * frame. `decode` normalizes `string | Uint8Array | ArrayBuffer` because React + * Native delivers binary WS frames as `ArrayBuffer`. + * + * The actual (de)compression primitive is injected via {@link RpcCompressionCodec} + * so `@t3tools/contracts` stays platform-agnostic: the server provides a + * `node:zlib` codec, the mobile client a `pako` codec. + */ +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import { type Parser, RpcSerialization } from "effect/unstable/rpc/RpcSerialization"; + +/** + * A synchronous, symmetric gzip primitive plus the byte threshold above which + * compression is applied. `compressSync` must gzip its input and + * `decompressSync` must be its exact inverse. + */ +export interface CompressionCodec { + readonly compressSync: (b: Uint8Array) => Uint8Array; + readonly decompressSync: (b: Uint8Array) => Uint8Array; + readonly threshold: number; +} + +/** + * Context reference holding the platform-specific {@link CompressionCodec}, + * or `null` when the platform provides none. Defaulting to `null` means only + * platforms that opt into the compressed transport need to provide it — + * `yield* RpcCompressionCodec` never fails, and a client without a codec + * simply keeps using the plain JSON transport. + */ +export class RpcCompressionCodec extends Context.Reference( + "@t3tools/contracts/rpcCompression/RpcCompressionCodec", + { defaultValue: () => null }, +) {} + +/** + * Content type for the compressed-JSON wire format. Distinct from + * `application/json` so a mismatched peer is not silently interpreted. + */ +export const COMPRESSED_JSON_CONTENT_TYPE = "application/x-t3-cjson"; + +/** Prefix byte marking a RAW (uncompressed utf8 JSON) frame body. */ +export const CJSON_PREFIX_RAW = 0x00; +/** Prefix byte marking a GZIP (gzipped utf8 JSON) frame body. */ +export const CJSON_PREFIX_GZIP = 0x01; + +/** + * Builds an `RpcSerialization` service value that gzips whole-message JSON + * payloads above `codec.threshold`, mirroring the shape of `json` so it can be + * dropped in wherever `json`/`layerJson` is used on a framed (WS) transport. + */ +export const makeCompressedJsonSerialization = ( + codec: CompressionCodec, +): RpcSerialization["Service"] => + RpcSerialization.of({ + contentType: COMPRESSED_JSON_CONTENT_TYPE, + includesFraming: false, + makeUnsafe: (): Parser => { + const enc = new TextEncoder(); + const dec = new TextDecoder(); + return { + encode: (response) => { + const json: string | undefined = JSON.stringify(response); + if (json === undefined) return undefined; + const utf8 = enc.encode(json); + if (utf8.length < codec.threshold) { + const out = new Uint8Array(utf8.length + 1); + out[0] = CJSON_PREFIX_RAW; + out.set(utf8, 1); + return out; + } + const gz = codec.compressSync(utf8); + const out = new Uint8Array(gz.length + 1); + out[0] = CJSON_PREFIX_GZIP; + out.set(gz, 1); + return out; + }, + decode: (data) => { + if (typeof data === "string") { + const parsed = JSON.parse(data); + return Array.isArray(parsed) ? parsed : [parsed]; + } + // React Native delivers binary frames as ArrayBuffer; normalize it. + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer); + const body = bytes.subarray(1); + const json = + bytes[0] === CJSON_PREFIX_GZIP + ? dec.decode(codec.decompressSync(body)) + : dec.decode(body); + const parsed = JSON.parse(json); + return Array.isArray(parsed) ? parsed : [parsed]; + }, + }; + }, + }); + +/** + * `RpcSerialization` layer for the compressed-JSON format, mirroring + * `RpcSerialization.layerJson`. Both ends must provide the same-shaped codec. + */ +export const layerCompressedJson = (codec: CompressionCodec): Layer.Layer => + Layer.succeed(RpcSerialization)(makeCompressedJsonSerialization(codec)); diff --git a/packages/contracts/src/threadSyncV2.test.ts b/packages/contracts/src/threadSyncV2.test.ts new file mode 100644 index 00000000000..e5b02a5127f --- /dev/null +++ b/packages/contracts/src/threadSyncV2.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { EnvironmentId } from "./baseSchemas.ts"; +import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { OrchestrationThreadV2StreamItem } from "./orchestration.ts"; + +describe("thread sync v2 contracts", () => { + it("defaults old descriptors to legacy JSON and sync v1", () => { + const descriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor)({ + environmentId: EnvironmentId.make("environment-1"), + label: "Desktop", + platform: { os: "windows", arch: "x64" }, + serverVersion: "0.0.1", + capabilities: { repositoryIdentity: false }, + }); + expect(descriptor.rpcTransports).toEqual([{ kind: "json", path: "/ws" }]); + expect(descriptor.threadSyncVersions).toEqual([1]); + }); + + it("decodes v2 staging and control frames", () => { + const decode = Schema.decodeUnknownSync(OrchestrationThreadV2StreamItem); + expect( + decode({ + kind: "snapshot-start", + snapshotId: "snapshot-1", + historyEpoch: 7, + watermark: 42, + chunkCount: 2, + inlineBytes: 1024, + }).kind, + ).toBe("snapshot-start"); + expect( + decode({ + kind: "snapshot-chunk", + snapshotId: "snapshot-1", + index: 0, + messages: [], + activities: [], + }).kind, + ).toBe("snapshot-chunk"); + expect( + decode({ + kind: "snapshot-complete", + snapshotId: "snapshot-1", + historyEpoch: 7, + lastAppliedSequence: 42, + before: { message: null, activity: null }, + hasOlderMessages: false, + hasOlderActivities: false, + }).kind, + ).toBe("snapshot-complete"); + expect(decode({ kind: "keepalive", sequence: 42 }).kind).toBe("keepalive"); + expect( + decode({ + kind: "catchup", + historyEpoch: 7, + fromSequence: 40, + toSequence: 42, + eventCount: 2, + }).kind, + ).toBe("catchup"); + expect(decode({ kind: "resync-required", reason: "live-buffer-overflow" }).kind).toBe( + "resync-required", + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed67594e846..556e8a87d9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -345,6 +345,9 @@ importers: expo-widgets: specifier: ~56.0.19 version: 56.0.19(3cdc0dde9f93166d952f1e1bd0cb25c0) + pako: + specifier: ^2.1.0 + version: 2.2.0 punycode: specifier: ^2.3.1 version: 2.3.1 @@ -409,6 +412,9 @@ importers: '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/pako': + specifier: ^2.0.3 + version: 2.0.4 '@types/react': specifier: ~19.2.0 version: 19.2.16 @@ -569,6 +575,9 @@ importers: lucide-react: specifier: ^0.564.0 version: 0.564.0(react@19.2.6) + pako: + specifier: ^2.1.0 + version: 2.2.0 react: specifier: 19.2.6 version: 19.2.6 @@ -615,6 +624,9 @@ importers: '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 + '@types/pako': + specifier: ^2.0.3 + version: 2.0.4 '@types/react': specifier: ~19.2.14 version: 19.2.16 @@ -4867,6 +4879,9 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -8423,6 +8438,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -14854,6 +14872,8 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/pako@2.0.4': {} + '@types/react-dom@19.2.3(@types/react@19.2.16)': dependencies: '@types/react': 19.2.16 @@ -19166,6 +19186,8 @@ snapshots: pako@1.0.11: {} + pako@2.2.0: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11