| author | |
| committer | |
| log | 8e3467fe60c49ee739b278229156108032b72e25 |
| tree | 5b44e2488e27969f366526faab7213f06f2a8922 |
| parent | 2fae0d0ac300353be774c9f9fe79d3af025d4f91 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>17 files changed, 779 insertions(+), 233 deletions(-)
apps/mobile/src/Stack.tsx+1-3| ... | ... | @@ -15,7 +15,6 @@ import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "re |
| 15 | 15 | import { useResolveClassNames } from "uniwind"; |
| 16 | 16 | |
| 17 | 17 | import { AppText as Text } from "./components/AppText"; |
| 18 | import { renderCompactBrandTitle } from "./components/CompactBrandTitle"; | |
| 19 | 18 | import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; |
| 20 | 19 | import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; |
| 21 | 20 | import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; |
| ... | ... | @@ -378,8 +377,7 @@ export const RootStack = createNativeStackNavigator({ |
| 378 | 377 | ...GLASS_HEADER_OPTIONS, |
| 379 | 378 | contentStyle: { backgroundColor: "transparent" }, |
| 380 | 379 | headerBackVisible: false, |
| 381 | headerTitle: renderCompactBrandTitle, | |
| 382 | title: "T3 Code", | |
| 380 | title: "Threads", | |
| 383 | 381 | }, |
| 384 | 382 | }), |
| 385 | 383 | Thread: createNativeStackScreen({ |
apps/mobile/src/components/CompactBrandTitle.tsx deleted-60| ... | ... | @@ -1,60 +0,0 @@ |
| 1 | import { View } from "react-native"; | |
| 2 | ||
| 3 | import { AppText as Text } from "./AppText"; | |
| 4 | import { T3Wordmark } from "./T3Wordmark"; | |
| 5 | import { useThemeColor } from "../lib/useThemeColor"; | |
| 6 | ||
| 7 | /** | |
| 8 | * Compact brand lockup sized for native navigation bars. | |
| 9 | */ | |
| 10 | export function CompactBrandTitle() { | |
| 11 | const iconColor = useThemeColor("--color-icon"); | |
| 12 | const mutedColor = useThemeColor("--color-foreground-muted"); | |
| 13 | const subtleColor = useThemeColor("--color-subtle"); | |
| 14 | ||
| 15 | return ( | |
| 16 | <View | |
| 17 | aria-level={1} | |
| 18 | accessibilityLabel="T3 Code" | |
| 19 | accessible | |
| 20 | role="heading" | |
| 21 | style={{ alignItems: "center", flexDirection: "row", gap: 6 }} | |
| 22 | > | |
| 23 | <T3Wordmark color={iconColor} height={11} /> | |
| 24 | <Text | |
| 25 | style={{ | |
| 26 | color: mutedColor, | |
| 27 | fontFamily: "DMSans-Medium", | |
| 28 | fontSize: 14, | |
| 29 | letterSpacing: -0.35, | |
| 30 | }} | |
| 31 | > | |
| 32 | Code | |
| 33 | </Text> | |
| 34 | <View | |
| 35 | style={{ | |
| 36 | backgroundColor: subtleColor, | |
| 37 | borderRadius: 999, | |
| 38 | paddingHorizontal: 6, | |
| 39 | paddingVertical: 2, | |
| 40 | }} | |
| 41 | > | |
| 42 | <Text | |
| 43 | style={{ | |
| 44 | color: mutedColor, | |
| 45 | fontFamily: "DMSans-Bold", | |
| 46 | fontSize: 9, | |
| 47 | letterSpacing: 0.9, | |
| 48 | textTransform: "uppercase", | |
| 49 | }} | |
| 50 | > | |
| 51 | Alpha | |
| 52 | </Text> | |
| 53 | </View> | |
| 54 | </View> | |
| 55 | ); | |
| 56 | } | |
| 57 | ||
| 58 | export function renderCompactBrandTitle() { | |
| 59 | return <CompactBrandTitle />; | |
| 60 | } |
apps/mobile/src/features/home/HomeRouteScreen.tsx+1-4| ... | ... | @@ -4,7 +4,6 @@ import { useNavigation } from "@react-navigation/native"; |
| 4 | 4 | import { useMemo, useState } from "react"; |
| 5 | 5 | |
| 6 | 6 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 7 | import { renderCompactBrandTitle } from "../../components/CompactBrandTitle"; | |
| 8 | 7 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 9 | 8 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; |
| 10 | 9 | import { useWorkspaceState } from "../../state/workspace"; |
| ... | ... | @@ -87,9 +86,7 @@ export function HomeRouteScreen() { |
| 87 | 86 | > |
| 88 | 87 | <> |
| 89 | 88 | {/* Restore the compact title in case the split branch blanked it. */} |
| 90 | <NativeStackScreenOptions | |
| 91 | options={{ title: "T3 Code", headerTitle: renderCompactBrandTitle }} | |
| 92 | /> | |
| 89 | <NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} /> | |
| 93 | 90 | <HomeHeader |
| 94 | 91 | environments={environments} |
| 95 | 92 | searchQuery={searchQuery} |
apps/mobile/src/features/home/HomeScreen.tsx+10-1| ... | ... | @@ -399,11 +399,20 @@ export function HomeScreen(props: HomeScreenProps) { |
| 399 | 399 | onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} |
| 400 | 400 | variant="plain" |
| 401 | 401 | /> |
| 402 | {emptyState.loading ? ( | |
| 402 | {emptyState.loading && !shouldShowConnectionStatus ? ( | |
| 403 | 403 | <View className="mt-4 items-center"> |
| 404 | 404 | <ActivityIndicator color={accentColor} /> |
| 405 | 405 | </View> |
| 406 | 406 | ) : null} |
| 407 | {shouldShowConnectionStatus && Platform.OS === "ios" ? ( | |
| 408 | <View className="mt-4"> | |
| 409 | <WorkspaceConnectionStatus | |
| 410 | state={props.catalogState} | |
| 411 | onPress={props.onOpenEnvironments} | |
| 412 | variant="sidebar" | |
| 413 | /> | |
| 414 | </View> | |
| 415 | ) : null} | |
| 407 | 416 | </View> |
| 408 | 417 | {connectionStatus} |
| 409 | 418 | </View> |
apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts+17| ... | ... | @@ -67,4 +67,21 @@ describe("workspace connection status", () => { |
| 67 | 67 | expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); |
| 68 | 68 | expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini"); |
| 69 | 69 | }); |
| 70 | ||
| 71 | it("shows shell catch-up while cached threads remain visible", () => { | |
| 72 | const state = workspaceState({ hasPendingShellSnapshot: true }); | |
| 73 | ||
| 74 | expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); | |
| 75 | expect(workspaceConnectionStatusLabel(state)).toBe("Syncing threads..."); | |
| 76 | }); | |
| 77 | ||
| 78 | it("distinguishes initial shell loading from cached catch-up", () => { | |
| 79 | const state = workspaceState({ | |
| 80 | hasLoadedShellSnapshot: false, | |
| 81 | hasPendingShellSnapshot: true, | |
| 82 | }); | |
| 83 | ||
| 84 | expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); | |
| 85 | expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads..."); | |
| 86 | }); | |
| 70 | 87 | }); |
apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx+5-2| ... | ... | @@ -12,7 +12,10 @@ export function WorkspaceConnectionStatus(props: { |
| 12 | 12 | readonly variant?: "floating" | "sidebar"; |
| 13 | 13 | }) { |
| 14 | 14 | const iconColor = useThemeColor("--color-icon-muted"); |
| 15 | const isReconnecting = props.state.connectingEnvironments.length > 0; | |
| 15 | const isSynchronizing = | |
| 16 | props.state.networkStatus !== "offline" && | |
| 17 | props.state.connectionError === null && | |
| 18 | (props.state.connectingEnvironments.length > 0 || props.state.hasPendingShellSnapshot); | |
| 16 | 19 | const variant = props.variant ?? "floating"; |
| 17 | 20 | |
| 18 | 21 | return ( |
| ... | ... | @@ -37,7 +40,7 @@ export function WorkspaceConnectionStatus(props: { |
| 37 | 40 | : undefined |
| 38 | 41 | } |
| 39 | 42 | > |
| 40 | {isReconnecting ? ( | |
| 43 | {isSynchronizing ? ( | |
| 41 | 44 | <ActivityIndicator color={iconColor} size="small" /> |
| 42 | 45 | ) : ( |
| 43 | 46 | <SymbolView name="wifi.slash" size={15} tintColor={iconColor} type="monochrome" /> |
apps/mobile/src/features/home/workspace-connection-status.ts+4| ... | ... | @@ -5,6 +5,7 @@ export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): bool |
| 5 | 5 | state.networkStatus === "offline" || |
| 6 | 6 | state.connectionError !== null || |
| 7 | 7 | state.hasConnectingEnvironment || |
| 8 | state.hasPendingShellSnapshot || | |
| 8 | 9 | (state.hasLoadedShellSnapshot && !state.hasReadyEnvironment) |
| 9 | 10 | ); |
| 10 | 11 | } |
| ... | ... | @@ -18,5 +19,8 @@ export function workspaceConnectionStatusLabel(state: WorkspaceState): string { |
| 18 | 19 | return `Reconnecting ${state.connectingEnvironments.length} environments`; |
| 19 | 20 | } |
| 20 | 21 | if (state.connectionError !== null) return state.connectionError; |
| 22 | if (state.hasPendingShellSnapshot) { | |
| 23 | return state.hasLoadedShellSnapshot ? "Syncing threads..." : "Loading threads..."; | |
| 24 | } | |
| 21 | 25 | return "Not connected"; |
| 22 | 26 | } |
apps/mobile/src/features/threads/sidebar-navigation-shell.tsx+1-3| ... | ... | @@ -11,7 +11,6 @@ import { |
| 11 | 11 | import type { ReactNode } from "react"; |
| 12 | 12 | import { Platform, useColorScheme } from "react-native"; |
| 13 | 13 | |
| 14 | import { renderCompactBrandTitle } from "../../components/CompactBrandTitle"; | |
| 15 | 14 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 16 | 15 | |
| 17 | 16 | const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); |
| ... | ... | @@ -35,11 +34,10 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = { |
| 35 | 34 | headerShadowVisible: false, |
| 36 | 35 | headerShown: true, |
| 37 | 36 | headerStyle: { backgroundColor: "transparent" }, |
| 38 | headerTitle: renderCompactBrandTitle, | |
| 39 | 37 | headerTitleStyle: { fontSize: 18, fontWeight: "800" }, |
| 40 | 38 | headerTransparent: true, |
| 41 | 39 | scrollEdgeEffects: SCROLL_EDGE_EFFECTS, |
| 42 | title: "T3 Code", | |
| 40 | title: "Threads", | |
| 43 | 41 | unstable_navigationItemStyle: "editor", |
| 44 | 42 | }; |
| 45 | 43 |
apps/server/src/server.test.ts+109| ... | ... | @@ -3717,6 +3717,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3717 | 3717 | |
| 3718 | 3718 | assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); |
| 3719 | 3719 | assert.equal(response.auth.policy, "desktop-managed-local"); |
| 3720 | assert.equal(response.shellResumeCompletionMarker, true); | |
| 3721 | assert.equal(response.threadResumeCompletionMarker, true); | |
| 3720 | 3722 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 3721 | 3723 | ); |
| 3722 | 3724 | |
| ... | ... | @@ -5607,6 +5609,113 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5607 | 5609 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 5608 | 5610 | ); |
| 5609 | 5611 | |
| 5612 | it.effect("marks an empty shell catch-up replay as synchronized when requested", () => | |
| 5613 | Effect.gen(function* () { | |
| 5614 | yield* buildAppUnderTest({ | |
| 5615 | layers: { | |
| 5616 | orchestrationEngine: { | |
| 5617 | readEvents: () => Stream.empty, | |
| 5618 | }, | |
| 5619 | }, | |
| 5620 | }); | |
| 5621 | ||
| 5622 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 5623 | const firstItem = yield* Effect.scoped( | |
| 5624 | withWsRpcClient(wsUrl, (client) => | |
| 5625 | client[ORCHESTRATION_WS_METHODS.subscribeShell]({ | |
| 5626 | afterSequence: 0, | |
| 5627 | requestCompletionMarker: true, | |
| 5628 | }).pipe(Stream.runHead), | |
| 5629 | ), | |
| 5630 | ); | |
| 5631 | ||
| 5632 | assert.deepEqual(Option.getOrThrow(firstItem), { kind: "synchronized" }); | |
| 5633 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 5634 | ); | |
| 5635 | ||
| 5636 | it.effect("marks a socket thread snapshot as synchronized when requested", () => | |
| 5637 | Effect.gen(function* () { | |
| 5638 | const thread = makeDefaultOrchestrationReadModel().threads[0]!; | |
| 5639 | yield* buildAppUnderTest({ | |
| 5640 | layers: { | |
| 5641 | projectionSnapshotQuery: { | |
| 5642 | getThreadDetailSnapshot: () => | |
| 5643 | Effect.succeed(Option.some({ snapshotSequence: 1, thread })), | |
| 5644 | }, | |
| 5645 | }, | |
| 5646 | }); | |
| 5647 | ||
| 5648 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 5649 | const items = yield* Effect.scoped( | |
| 5650 | withWsRpcClient(wsUrl, (client) => | |
| 5651 | client[ORCHESTRATION_WS_METHODS.subscribeThread]({ | |
| 5652 | threadId: defaultThreadId, | |
| 5653 | requestCompletionMarker: true, | |
| 5654 | }).pipe(Stream.take(2), Stream.runCollect), | |
| 5655 | ), | |
| 5656 | ); | |
| 5657 | ||
| 5658 | assert.equal(items[0]?.kind, "snapshot"); | |
| 5659 | assert.deepEqual(items[1], { kind: "synchronized" }); | |
| 5660 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 5661 | ); | |
| 5662 | ||
| 5663 | it.effect("buffers shell events published while the fallback snapshot loads", () => | |
| 5664 | Effect.gen(function* () { | |
| 5665 | const liveEvents = yield* PubSub.unbounded<OrchestrationEvent>(); | |
| 5666 | const deletedEvent = { | |
| 5667 | sequence: 2, | |
| 5668 | eventId: EventId.make("event-shell-thread-deleted"), | |
| 5669 | aggregateKind: "thread", | |
| 5670 | aggregateId: defaultThreadId, | |
| 5671 | occurredAt: "2026-01-01T00:00:01.000Z", | |
| 5672 | commandId: null, | |
| 5673 | causationEventId: null, | |
| 5674 | correlationId: null, | |
| 5675 | metadata: {}, | |
| 5676 | type: "thread.deleted", | |
| 5677 | payload: { | |
| 5678 | threadId: defaultThreadId, | |
| 5679 | deletedAt: "2026-01-01T00:00:01.000Z", | |
| 5680 | }, | |
| 5681 | } satisfies Extract<OrchestrationEvent, { type: "thread.deleted" }>; | |
| 5682 | ||
| 5683 | yield* buildAppUnderTest({ | |
| 5684 | layers: { | |
| 5685 | orchestrationEngine: { | |
| 5686 | streamDomainEvents: Stream.fromPubSub(liveEvents), | |
| 5687 | }, | |
| 5688 | projectionSnapshotQuery: { | |
| 5689 | getShellSnapshot: () => | |
| 5690 | Effect.gen(function* () { | |
| 5691 | yield* Effect.sleep("25 millis"); | |
| 5692 | yield* PubSub.publish(liveEvents, deletedEvent); | |
| 5693 | return { | |
| 5694 | snapshotSequence: 1, | |
| 5695 | projects: [], | |
| 5696 | threads: [makeDefaultOrchestrationThreadShell()], | |
| 5697 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 5698 | }; | |
| 5699 | }), | |
| 5700 | }, | |
| 5701 | }, | |
| 5702 | }); | |
| 5703 | ||
| 5704 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 5705 | const items = yield* Effect.scoped( | |
| 5706 | withWsRpcClient(wsUrl, (client) => | |
| 5707 | client[ORCHESTRATION_WS_METHODS.subscribeShell]({ | |
| 5708 | requestCompletionMarker: true, | |
| 5709 | }).pipe(Stream.take(3), Stream.runCollect), | |
| 5710 | ), | |
| 5711 | ).pipe(Effect.timeout("2 seconds")); | |
| 5712 | ||
| 5713 | assert.equal(items[0]?.kind, "snapshot"); | |
| 5714 | assert.equal(items[1]?.kind, "thread-removed"); | |
| 5715 | assert.deepEqual(items[2], { kind: "synchronized" }); | |
| 5716 | }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), | |
| 5717 | ); | |
| 5718 | ||
| 5610 | 5719 | it.effect("buffers thread events published while the initial snapshot loads", () => |
| 5611 | 5720 | Effect.gen(function* () { |
| 5612 | 5721 | const thread = makeDefaultOrchestrationReadModel().threads[0]!; |
apps/server/src/ws.ts+50-4| ... | ... | @@ -936,6 +936,8 @@ const makeWsRpcLayer = ( |
| 936 | 936 | otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, |
| 937 | 937 | }, |
| 938 | 938 | settings, |
| 939 | shellResumeCompletionMarker: true, | |
| 940 | threadResumeCompletionMarker: true, | |
| 939 | 941 | }; |
| 940 | 942 | }); |
| 941 | 943 | |
| ... | ... | @@ -1105,11 +1107,28 @@ const makeWsRpcLayer = ( |
| 1105 | 1107 | }), |
| 1106 | 1108 | ), |
| 1107 | 1109 | ); |
| 1108 | return Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer)); | |
| 1110 | const afterCatchUp = | |
| 1111 | input.requestCompletionMarker === true | |
| 1112 | ? Stream.concat( | |
| 1113 | Stream.fromEffect( | |
| 1114 | Queue.offer(liveBuffer, { kind: "synchronized" as const }), | |
| 1115 | ).pipe(Stream.drain), | |
| 1116 | Stream.fromQueue(liveBuffer), | |
| 1117 | ) | |
| 1118 | : Stream.fromQueue(liveBuffer); | |
| 1119 | return Stream.concat(catchUpStream, afterCatchUp); | |
| 1109 | 1120 | }), |
| 1110 | 1121 | ); |
| 1111 | 1122 | } |
| 1112 | 1123 | |
| 1124 | // The full-snapshot fallback needs the same replay-window safety | |
| 1125 | // as the resume path: subscribe before loading the projection so | |
| 1126 | // events published while the snapshot is read are buffered. | |
| 1127 | const liveBuffer = yield* Queue.unbounded<OrchestrationShellStreamItem>(); | |
| 1128 | yield* Effect.forkScoped( | |
| 1129 | liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))), | |
| 1130 | ); | |
| 1131 | const bufferedLiveStream = Stream.fromQueue(liveBuffer); | |
| 1113 | 1132 | const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( |
| 1114 | 1133 | Effect.tapError((cause) => |
| 1115 | 1134 | Effect.logError("orchestration shell snapshot load failed", { cause }), |
| ... | ... | @@ -1123,12 +1142,21 @@ const makeWsRpcLayer = ( |
| 1123 | 1142 | ), |
| 1124 | 1143 | ); |
| 1125 | 1144 | |
| 1145 | const afterSnapshot = | |
| 1146 | input.requestCompletionMarker === true | |
| 1147 | ? Stream.concat( | |
| 1148 | Stream.fromEffect( | |
| 1149 | Queue.offer(liveBuffer, { kind: "synchronized" as const }), | |
| 1150 | ).pipe(Stream.drain), | |
| 1151 | bufferedLiveStream, | |
| 1152 | ) | |
| 1153 | : bufferedLiveStream; | |
| 1126 | 1154 | return Stream.concat( |
| 1127 | 1155 | Stream.make({ |
| 1128 | 1156 | kind: "snapshot" as const, |
| 1129 | 1157 | snapshot, |
| 1130 | 1158 | }), |
| 1131 | liveStream, | |
| 1159 | afterSnapshot, | |
| 1132 | 1160 | ); |
| 1133 | 1161 | }), |
| 1134 | 1162 | { "rpc.aggregate": "orchestration" }, |
| ... | ... | @@ -1207,7 +1235,16 @@ const makeWsRpcLayer = ( |
| 1207 | 1235 | }), |
| 1208 | 1236 | ), |
| 1209 | 1237 | ); |
| 1210 | return Stream.concat(catchUpStream, bufferedLiveStream); | |
| 1238 | const afterCatchUp = | |
| 1239 | input.requestCompletionMarker === true | |
| 1240 | ? Stream.concat( | |
| 1241 | Stream.fromEffect( | |
| 1242 | Queue.offer(liveBuffer, { kind: "synchronized" as const }), | |
| 1243 | ).pipe(Stream.drain), | |
| 1244 | bufferedLiveStream, | |
| 1245 | ) | |
| 1246 | : bufferedLiveStream; | |
| 1247 | return Stream.concat(catchUpStream, afterCatchUp); | |
| 1211 | 1248 | } |
| 1212 | 1249 | |
| 1213 | 1250 | const snapshot = yield* projectionSnapshotQuery |
| ... | ... | @@ -1229,12 +1266,21 @@ const makeWsRpcLayer = ( |
| 1229 | 1266 | }); |
| 1230 | 1267 | } |
| 1231 | 1268 | |
| 1269 | const afterSnapshot = | |
| 1270 | input.requestCompletionMarker === true | |
| 1271 | ? Stream.concat( | |
| 1272 | Stream.fromEffect( | |
| 1273 | Queue.offer(liveBuffer, { kind: "synchronized" as const }), | |
| 1274 | ).pipe(Stream.drain), | |
| 1275 | bufferedLiveStream, | |
| 1276 | ) | |
| 1277 | : bufferedLiveStream; | |
| 1232 | 1278 | return Stream.concat( |
| 1233 | 1279 | Stream.make({ |
| 1234 | 1280 | kind: "snapshot" as const, |
| 1235 | 1281 | snapshot: snapshot.value, |
| 1236 | 1282 | }), |
| 1237 | bufferedLiveStream, | |
| 1283 | afterSnapshot, | |
| 1238 | 1284 | ); |
| 1239 | 1285 | }), |
| 1240 | 1286 | { "rpc.aggregate": "orchestration" }, |
packages/client-runtime/src/rpc/client.ts+88-52| ... | ... | @@ -11,6 +11,7 @@ import { RpcClientError } from "effect/unstable/rpc"; |
| 11 | 11 | |
| 12 | 12 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; |
| 13 | 13 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 14 | import type { RpcSession } from "../rpc/session.ts"; | |
| 14 | 15 | |
| 15 | 16 | export class EnvironmentRpcUnavailableError extends Schema.TaggedErrorClass<EnvironmentRpcUnavailableError>()( |
| 16 | 17 | "EnvironmentRpcUnavailableError", |
| ... | ... | @@ -147,15 +148,18 @@ export function runStream<TTag extends EnvironmentStreamCommandRpcTag>( |
| 147 | 148 | ); |
| 148 | 149 | } |
| 149 | 150 | |
| 150 | export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>( | |
| 151 | interface SubscriptionOptions<TTag extends EnvironmentSubscriptionRpcTag> { | |
| 152 | readonly onExpectedFailure?: ( | |
| 153 | cause: Cause.Cause<EnvironmentRpcStreamFailure<TTag>>, | |
| 154 | ) => Effect.Effect<void, never, never>; | |
| 155 | readonly retryExpectedFailureAfter?: Duration.Input; | |
| 156 | readonly resubscribe?: Stream.Stream<unknown, never, never>; | |
| 157 | } | |
| 158 | ||
| 159 | export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>( | |
| 151 | 160 | tag: TTag, |
| 152 | input: EnvironmentRpcInput<TTag>, | |
| 153 | options?: { | |
| 154 | readonly onExpectedFailure?: ( | |
| 155 | cause: Cause.Cause<EnvironmentRpcStreamFailure<TTag>>, | |
| 156 | ) => Effect.Effect<void, never, never>; | |
| 157 | readonly retryExpectedFailureAfter?: Duration.Input; | |
| 158 | }, | |
| 161 | makeInput: (session: RpcSession) => Effect.Effect<EnvironmentRpcInput<TTag>>, | |
| 162 | options?: SubscriptionOptions<TTag>, | |
| 159 | 163 | ): Stream.Stream< |
| 160 | 164 | EnvironmentRpcStreamValue<TTag>, |
| 161 | 165 | EnvironmentRpcStreamFailure<TTag>, |
| ... | ... | @@ -163,8 +167,18 @@ export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>( |
| 163 | 167 | > { |
| 164 | 168 | return Stream.unwrap( |
| 165 | 169 | EnvironmentSupervisor.pipe( |
| 166 | Effect.map((supervisor) => | |
| 167 | SubscriptionRef.changes(supervisor.session).pipe( | |
| 170 | Effect.map((supervisor) => { | |
| 171 | const sessionChanges = SubscriptionRef.changes(supervisor.session); | |
| 172 | const sessions = | |
| 173 | options?.resubscribe === undefined | |
| 174 | ? sessionChanges | |
| 175 | : Stream.merge( | |
| 176 | sessionChanges, | |
| 177 | options.resubscribe.pipe( | |
| 178 | Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)), | |
| 179 | ), | |
| 180 | ); | |
| 181 | return sessions.pipe( | |
| 168 | 182 | Stream.switchMap( |
| 169 | 183 | Option.match({ |
| 170 | 184 | onNone: () => Stream.empty, |
| ... | ... | @@ -180,54 +194,64 @@ export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>( |
| 180 | 194 | EnvironmentRpcStreamFailure<TTag> |
| 181 | 195 | > => |
| 182 | 196 | Stream.suspend(() => |
| 183 | method(input).pipe( | |
| 184 | Stream.catchCause((cause) => { | |
| 185 | const hasOnlyExpectedFailures = | |
| 186 | cause.reasons.length > 0 && | |
| 187 | cause.reasons.every((reason) => reason._tag === "Fail"); | |
| 188 | const isTransportFailure = | |
| 189 | hasOnlyExpectedFailures && | |
| 190 | cause.reasons.every( | |
| 191 | (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), | |
| 192 | ); | |
| 193 | if (isTransportFailure) { | |
| 194 | return Stream.fromEffect( | |
| 195 | Effect.logWarning( | |
| 196 | "Durable RPC subscription lost its transport; waiting for the next session.", | |
| 197 | { | |
| 198 | cause: Cause.pretty(cause), | |
| 199 | method: tag, | |
| 200 | environmentId: supervisor.target.environmentId, | |
| 201 | }, | |
| 202 | ), | |
| 203 | ).pipe(Stream.drain); | |
| 204 | } | |
| 205 | if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { | |
| 206 | const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( | |
| 207 | Stream.drain, | |
| 208 | ); | |
| 209 | if (options.retryExpectedFailureAfter === undefined) { | |
| 210 | return handled; | |
| 211 | } | |
| 212 | return handled.pipe( | |
| 213 | Stream.concat( | |
| 214 | Stream.fromEffect( | |
| 215 | Effect.sleep(options.retryExpectedFailureAfter), | |
| 216 | ).pipe(Stream.drain), | |
| 217 | ), | |
| 218 | Stream.concat(subscribeToSession()), | |
| 219 | ); | |
| 220 | } | |
| 221 | return Stream.failCause(cause); | |
| 222 | }), | |
| 197 | Stream.unwrap( | |
| 198 | makeInput(session).pipe( | |
| 199 | Effect.map((input) => | |
| 200 | method(input).pipe( | |
| 201 | Stream.catchCause((cause) => { | |
| 202 | const hasOnlyExpectedFailures = | |
| 203 | cause.reasons.length > 0 && | |
| 204 | cause.reasons.every((reason) => reason._tag === "Fail"); | |
| 205 | const isTransportFailure = | |
| 206 | hasOnlyExpectedFailures && | |
| 207 | cause.reasons.every( | |
| 208 | (reason) => | |
| 209 | reason._tag === "Fail" && isRpcClientError(reason.error), | |
| 210 | ); | |
| 211 | if (isTransportFailure) { | |
| 212 | return Stream.fromEffect( | |
| 213 | Effect.logWarning( | |
| 214 | "Durable RPC subscription lost its transport; waiting for the next session.", | |
| 215 | { | |
| 216 | cause: Cause.pretty(cause), | |
| 217 | method: tag, | |
| 218 | environmentId: supervisor.target.environmentId, | |
| 219 | }, | |
| 220 | ), | |
| 221 | ).pipe(Stream.drain); | |
| 222 | } | |
| 223 | if ( | |
| 224 | hasOnlyExpectedFailures && | |
| 225 | options?.onExpectedFailure !== undefined | |
| 226 | ) { | |
| 227 | const handled = Stream.fromEffect( | |
| 228 | options.onExpectedFailure(cause), | |
| 229 | ).pipe(Stream.drain); | |
| 230 | if (options.retryExpectedFailureAfter === undefined) { | |
| 231 | return handled; | |
| 232 | } | |
| 233 | return handled.pipe( | |
| 234 | Stream.concat( | |
| 235 | Stream.fromEffect( | |
| 236 | Effect.sleep(options.retryExpectedFailureAfter), | |
| 237 | ).pipe(Stream.drain), | |
| 238 | ), | |
| 239 | Stream.concat(subscribeToSession()), | |
| 240 | ); | |
| 241 | } | |
| 242 | return Stream.failCause(cause); | |
| 243 | }), | |
| 244 | ), | |
| 245 | ), | |
| 246 | ), | |
| 223 | 247 | ), |
| 224 | 248 | ); |
| 225 | 249 | return subscribeToSession(); |
| 226 | 250 | }, |
| 227 | 251 | }), |
| 228 | 252 | ), |
| 229 | ), | |
| 230 | ), | |
| 253 | ); | |
| 254 | }), | |
| 231 | 255 | ), |
| 232 | 256 | ).pipe( |
| 233 | 257 | Stream.withSpan("EnvironmentRpc.subscribe", { |
| ... | ... | @@ -236,6 +260,18 @@ export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>( |
| 236 | 260 | ); |
| 237 | 261 | } |
| 238 | 262 | |
| 263 | export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>( | |
| 264 | tag: TTag, | |
| 265 | input: EnvironmentRpcInput<TTag>, | |
| 266 | options?: SubscriptionOptions<TTag>, | |
| 267 | ): Stream.Stream< | |
| 268 | EnvironmentRpcStreamValue<TTag>, | |
| 269 | EnvironmentRpcStreamFailure<TTag>, | |
| 270 | EnvironmentSupervisor | |
| 271 | > { | |
| 272 | return subscribeDynamic(tag, () => Effect.succeed(input), options); | |
| 273 | } | |
| 274 | ||
| 239 | 275 | export const config = Effect.gen(function* () { |
| 240 | 276 | const session = yield* currentSession(); |
| 241 | 277 | return yield* session.initialConfig; |
packages/client-runtime/src/state/shell-sync.test.ts+133-9| ... | ... | @@ -8,6 +8,7 @@ import { describe, expect, it } from "@effect/vitest"; |
| 8 | 8 | import * as Effect from "effect/Effect"; |
| 9 | 9 | import * as Option from "effect/Option"; |
| 10 | 10 | import * as Queue from "effect/Queue"; |
| 11 | import * as Ref from "effect/Ref"; | |
| 11 | 12 | import * as Stream from "effect/Stream"; |
| 12 | 13 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 13 | 14 | |
| ... | ... | @@ -17,6 +18,7 @@ import { |
| 17 | 18 | type PreparedConnection, |
| 18 | 19 | } from "../connection/model.ts"; |
| 19 | 20 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 21 | import * as ConnectionWakeups from "../connection/wakeups.ts"; | |
| 20 | 22 | import * as Persistence from "../platform/persistence.ts"; |
| 21 | 23 | import * as RpcSession from "../rpc/session.ts"; |
| 22 | 24 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| ... | ... | @@ -48,7 +50,7 @@ const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = { |
| 48 | 50 | function session(client: WsRpcProtocolClient): RpcSession.RpcSession { |
| 49 | 51 | return { |
| 50 | 52 | client, |
| 51 | initialConfig: Effect.never, | |
| 53 | initialConfig: Effect.succeed({ shellResumeCompletionMarker: true } as never), | |
| 52 | 54 | ready: Effect.void, |
| 53 | 55 | probe: Effect.void, |
| 54 | 56 | closed: Effect.never, |
| ... | ... | @@ -112,6 +114,15 @@ describe("environment shell synchronization", () => { |
| 112 | 114 | kind: "snapshot", |
| 113 | 115 | snapshot: LIVE_SHELL_SNAPSHOT, |
| 114 | 116 | }); |
| 117 | const synchronizing = yield* SubscriptionRef.changes(shellState).pipe( | |
| 118 | Stream.filter((state) => state.status === "synchronizing" && Option.isSome(state.snapshot)), | |
| 119 | Stream.runHead, | |
| 120 | ); | |
| 121 | expect(Option.getOrThrow(Option.getOrThrow(synchronizing).snapshot)).toEqual( | |
| 122 | LIVE_SHELL_SNAPSHOT, | |
| 123 | ); | |
| 124 | ||
| 125 | yield* Queue.offer(events, { kind: "synchronized" }); | |
| 115 | 126 | yield* SubscriptionRef.changes(shellState).pipe( |
| 116 | 127 | Stream.filter((state) => state.status === "live"), |
| 117 | 128 | Stream.runHead, |
| ... | ... | @@ -137,21 +148,32 @@ describe("environment shell synchronization", () => { |
| 137 | 148 | }), |
| 138 | 149 | ); |
| 139 | 150 | |
| 140 | it.effect("resumes a warm shell cache via afterSequence without an HTTP fetch", () => | |
| 151 | it.effect("replaces a warm shell cache with an authoritative HTTP snapshot", () => | |
| 141 | 152 | Effect.gen(function* () { |
| 142 | 153 | const cachedSnapshot: OrchestrationShellSnapshot = { |
| 143 | 154 | snapshotSequence: 5, |
| 144 | 155 | projects: [], |
| 145 | threads: [], | |
| 156 | threads: [{ id: "stale-thread" } as never], | |
| 146 | 157 | updatedAt: "2026-06-06T00:00:00.000Z", |
| 147 | 158 | }; |
| 159 | const httpSnapshot: OrchestrationShellSnapshot = { | |
| 160 | ...cachedSnapshot, | |
| 161 | snapshotSequence: 9, | |
| 162 | threads: [], | |
| 163 | updatedAt: "2026-06-07T00:00:00.000Z", | |
| 164 | }; | |
| 148 | 165 | const events = yield* Queue.unbounded<OrchestrationShellStreamItem>(); |
| 149 | 166 | const capturedAfterSequence = yield* SubscriptionRef.make<number | undefined>(undefined); |
| 167 | const capturedCompletionMarker = yield* Ref.make<boolean | undefined>(undefined); | |
| 150 | 168 | const loaderCalls = yield* SubscriptionRef.make(0); |
| 151 | 169 | const client = { |
| 152 | [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) => | |
| 170 | [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { | |
| 171 | readonly afterSequence?: number; | |
| 172 | readonly requestCompletionMarker?: boolean; | |
| 173 | }) => | |
| 153 | 174 | Stream.unwrap( |
| 154 | SubscriptionRef.set(capturedAfterSequence, input.afterSequence).pipe( | |
| 175 | Ref.set(capturedCompletionMarker, input.requestCompletionMarker).pipe( | |
| 176 | Effect.andThen(SubscriptionRef.set(capturedAfterSequence, input.afterSequence)), | |
| 155 | 177 | Effect.as(Stream.fromQueue(events)), |
| 156 | 178 | ), |
| 157 | 179 | ), |
| ... | ... | @@ -183,9 +205,11 @@ describe("environment shell synchronization", () => { |
| 183 | 205 | }); |
| 184 | 206 | const snapshotLoader = ShellSnapshotLoader.of({ |
| 185 | 207 | load: () => |
| 186 | SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe(Effect.as(Option.none())), | |
| 208 | SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe( | |
| 209 | Effect.as(Option.some(httpSnapshot)), | |
| 210 | ), | |
| 187 | 211 | }); |
| 188 | yield* makeEnvironmentShellState().pipe( | |
| 212 | const shellState = yield* makeEnvironmentShellState().pipe( | |
| 189 | 213 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 190 | 214 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), |
| 191 | 215 | Effect.provideService(ShellSnapshotLoader, snapshotLoader), |
| ... | ... | @@ -197,8 +221,108 @@ describe("environment shell synchronization", () => { |
| 197 | 221 | Stream.runHead, |
| 198 | 222 | ); |
| 199 | 223 | |
| 200 | expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(5); | |
| 201 | expect(yield* SubscriptionRef.get(loaderCalls)).toBe(0); | |
| 224 | expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(9); | |
| 225 | expect(yield* Ref.get(capturedCompletionMarker)).toBe(true); | |
| 226 | expect(yield* SubscriptionRef.get(loaderCalls)).toBe(1); | |
| 227 | const synchronizing = yield* SubscriptionRef.get(shellState); | |
| 228 | expect(synchronizing.status).toBe("synchronizing"); | |
| 229 | expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(httpSnapshot); | |
| 230 | ||
| 231 | yield* Queue.offer(events, { kind: "synchronized" }); | |
| 232 | yield* SubscriptionRef.changes(shellState).pipe( | |
| 233 | Stream.filter((value) => value.status === "live"), | |
| 234 | Stream.runHead, | |
| 235 | ); | |
| 236 | }), | |
| 237 | ); | |
| 238 | ||
| 239 | it.effect("refreshes the authoritative shell snapshot when the app becomes active", () => | |
| 240 | Effect.gen(function* () { | |
| 241 | const events = yield* Queue.unbounded<OrchestrationShellStreamItem>(); | |
| 242 | const wakeups = yield* Queue.unbounded<ConnectionWakeups.ConnectionWakeup>(); | |
| 243 | const loaderCalls = yield* Ref.make(0); | |
| 244 | const subscriptionCount = yield* Ref.make(0); | |
| 245 | const client = { | |
| 246 | [ORCHESTRATION_WS_METHODS.subscribeShell]: () => | |
| 247 | Stream.unwrap( | |
| 248 | Ref.update(subscriptionCount, (count) => count + 1).pipe( | |
| 249 | Effect.as(Stream.fromQueue(events)), | |
| 250 | ), | |
| 251 | ), | |
| 252 | } as unknown as WsRpcProtocolClient; | |
| 253 | const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); | |
| 254 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | |
| 255 | target: TARGET, | |
| 256 | state: supervisorState, | |
| 257 | session: yield* SubscriptionRef.make(Option.some(session(client))), | |
| 258 | prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), | |
| 259 | connect: Effect.void, | |
| 260 | disconnect: Effect.void, | |
| 261 | retryNow: Effect.void, | |
| 262 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | |
| 263 | const cache = Persistence.EnvironmentCacheStore.of({ | |
| 264 | loadShell: () => Effect.succeed(Option.some(LIVE_SHELL_SNAPSHOT)), | |
| 265 | saveShell: () => Effect.void, | |
| 266 | loadThread: () => Effect.succeed(Option.none()), | |
| 267 | saveThread: () => Effect.void, | |
| 268 | removeThread: () => Effect.void, | |
| 269 | loadServerConfig: () => Effect.succeed(Option.none()), | |
| 270 | saveServerConfig: () => Effect.void, | |
| 271 | loadVcsRefs: () => Effect.succeed(Option.none()), | |
| 272 | saveVcsRefs: () => Effect.void, | |
| 273 | clear: () => Effect.void, | |
| 274 | }); | |
| 275 | const snapshotLoader = ShellSnapshotLoader.of({ | |
| 276 | load: () => | |
| 277 | Ref.updateAndGet(loaderCalls, (count) => count + 1).pipe( | |
| 278 | Effect.map((count) => | |
| 279 | Option.some({ ...LIVE_SHELL_SNAPSHOT, snapshotSequence: count * 10 }), | |
| 280 | ), | |
| 281 | ), | |
| 282 | }); | |
| 283 | const shellState = yield* makeEnvironmentShellState().pipe( | |
| 284 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 285 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), | |
| 286 | Effect.provideService(ShellSnapshotLoader, snapshotLoader), | |
| 287 | Effect.provideService( | |
| 288 | ConnectionWakeups.ConnectionWakeups, | |
| 289 | ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.fromQueue(wakeups) }), | |
| 290 | ), | |
| 291 | ); | |
| 292 | ||
| 293 | yield* SubscriptionRef.changes(shellState).pipe( | |
| 294 | Stream.filter( | |
| 295 | (value) => | |
| 296 | value.status === "synchronizing" && | |
| 297 | Option.isSome(value.snapshot) && | |
| 298 | value.snapshot.value.snapshotSequence === 10, | |
| 299 | ), | |
| 300 | Stream.runHead, | |
| 301 | ); | |
| 302 | yield* Queue.offer(events, { kind: "synchronized" }); | |
| 303 | yield* SubscriptionRef.changes(shellState).pipe( | |
| 304 | Stream.filter((value) => value.status === "live"), | |
| 305 | Stream.runHead, | |
| 306 | ); | |
| 307 | ||
| 308 | yield* Queue.offer(wakeups, "application-active"); | |
| 309 | yield* SubscriptionRef.changes(shellState).pipe( | |
| 310 | Stream.filter( | |
| 311 | (value) => | |
| 312 | value.status === "synchronizing" && | |
| 313 | Option.isSome(value.snapshot) && | |
| 314 | value.snapshot.value.snapshotSequence === 20, | |
| 315 | ), | |
| 316 | Stream.runHead, | |
| 317 | ); | |
| 318 | ||
| 319 | for (let attempt = 0; attempt < 100; attempt += 1) { | |
| 320 | if ((yield* Ref.get(subscriptionCount)) >= 2) break; | |
| 321 | yield* Effect.yieldNow; | |
| 322 | } | |
| 323 | ||
| 324 | expect(yield* Ref.get(loaderCalls)).toBe(2); | |
| 325 | expect(yield* Ref.get(subscriptionCount)).toBe(2); | |
| 202 | 326 | }), |
| 203 | 327 | ); |
| 204 | 328 | }); |
packages/client-runtime/src/state/shell.ts+72-41| ... | ... | @@ -9,6 +9,7 @@ import * as Cause from "effect/Cause"; |
| 9 | 9 | import * as Effect from "effect/Effect"; |
| 10 | 10 | import * as Option from "effect/Option"; |
| 11 | 11 | import * as Queue from "effect/Queue"; |
| 12 | import * as Ref from "effect/Ref"; | |
| 12 | 13 | import * as Stream from "effect/Stream"; |
| 13 | 14 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 14 | 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| ... | ... | @@ -16,9 +17,10 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 16 | 17 | import { EnvironmentRegistry } from "../connection/registry.ts"; |
| 17 | 18 | import { connectionProjectionPhase } from "../connection/model.ts"; |
| 18 | 19 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; |
| 20 | import * as ConnectionWakeups from "../connection/wakeups.ts"; | |
| 19 | 21 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; |
| 20 | 22 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; |
| 21 | import { subscribe } from "../rpc/client.ts"; | |
| 23 | import { subscribeDynamic } from "../rpc/client.ts"; | |
| 22 | 24 | import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; |
| 23 | 25 | import { applyShellStreamEvent } from "./shellReducer.ts"; |
| 24 | 26 | import type { EnvironmentCatalogState } from "./connections.ts"; |
| ... | ... | @@ -50,6 +52,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 50 | 52 | const supervisor = yield* EnvironmentSupervisor; |
| 51 | 53 | const cache = yield* EnvironmentCacheStore; |
| 52 | 54 | const snapshotLoader = yield* ShellSnapshotLoader; |
| 55 | const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); | |
| 53 | 56 | const environmentId = supervisor.target.environmentId; |
| 54 | 57 | const cachedSnapshot = yield* cache.loadShell(environmentId).pipe( |
| 55 | 58 | Effect.catch((error) => |
| ... | ... | @@ -67,6 +70,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 67 | 70 | status: shellStatusForSnapshot(cachedSnapshot), |
| 68 | 71 | error: Option.none(), |
| 69 | 72 | }); |
| 73 | const awaitingCompletion = yield* Ref.make(false); | |
| 70 | 74 | const persistence = yield* Queue.sliding<OrchestrationShellSnapshot>(1); |
| 71 | 75 | |
| 72 | 76 | const persist = Effect.fn("EnvironmentShellState.persist")(function* ( |
| ... | ... | @@ -90,10 +94,14 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 90 | 94 | Effect.forkScoped, |
| 91 | 95 | ); |
| 92 | 96 | |
| 93 | const setDisconnected = SubscriptionRef.update(state, (current) => ({ | |
| 94 | ...current, | |
| 95 | status: shellStatusForSnapshot(current.snapshot), | |
| 96 | })); | |
| 97 | const setDisconnected = Ref.set(awaitingCompletion, false).pipe( | |
| 98 | Effect.andThen( | |
| 99 | SubscriptionRef.update(state, (current) => ({ | |
| 100 | ...current, | |
| 101 | status: shellStatusForSnapshot(current.snapshot), | |
| 102 | })), | |
| 103 | ), | |
| 104 | ); | |
| 97 | 105 | const setSynchronizing = SubscriptionRef.update(state, (current) => ({ |
| 98 | 106 | ...current, |
| 99 | 107 | status: "synchronizing" as const, |
| ... | ... | @@ -109,7 +117,8 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 109 | 117 | }, |
| 110 | 118 | ); |
| 111 | 119 | const setStreamError = (error: unknown) => |
| 112 | Effect.logWarning("Could not synchronize the environment shell.").pipe( | |
| 120 | Ref.set(awaitingCompletion, false).pipe( | |
| 121 | Effect.andThen(Effect.logWarning("Could not synchronize the environment shell.")), | |
| 113 | 122 | Effect.annotateLogs({ |
| 114 | 123 | environmentId, |
| 115 | 124 | ...safeErrorLogAttributes(error), |
| ... | ... | @@ -126,6 +135,16 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 126 | 135 | const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( |
| 127 | 136 | item: OrchestrationShellStreamItem, |
| 128 | 137 | ) { |
| 138 | if (item.kind === "synchronized") { | |
| 139 | yield* Ref.set(awaitingCompletion, false); | |
| 140 | yield* SubscriptionRef.update(state, (current) => | |
| 141 | Option.isSome(current.snapshot) | |
| 142 | ? { ...current, status: "live" as const, error: Option.none() } | |
| 143 | : current, | |
| 144 | ); | |
| 145 | return; | |
| 146 | } | |
| 147 | ||
| 129 | 148 | const current = yield* SubscriptionRef.get(state); |
| 130 | 149 | const nextSnapshot = |
| 131 | 150 | item.kind === "snapshot" |
| ... | ... | @@ -141,52 +160,64 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 141 | 160 | return; |
| 142 | 161 | } |
| 143 | 162 | |
| 163 | const waiting = yield* Ref.get(awaitingCompletion); | |
| 144 | 164 | yield* SubscriptionRef.set(state, { |
| 145 | 165 | snapshot: Option.some(nextSnapshot), |
| 146 | status: "live", | |
| 166 | status: waiting ? "synchronizing" : "live", | |
| 147 | 167 | error: Option.none(), |
| 148 | 168 | }); |
| 149 | 169 | yield* Queue.offer(persistence, nextSnapshot); |
| 150 | 170 | }); |
| 151 | 171 | |
| 152 | yield* Effect.forkScoped( | |
| 153 | Effect.gen(function* () { | |
| 154 | // Establish the base shell snapshot to resume from, minimizing bytes over | |
| 155 | // the wire: | |
| 156 | // - Warm cache: reuse the cached snapshot (zero network) and resume via | |
| 157 | // `afterSequence` so we only receive shell events since the cached | |
| 158 | // sequence. | |
| 159 | // - Cold cache: load the full shell snapshot over HTTP (gzip-compressible, | |
| 160 | // and off the socket), then resume via `afterSequence`. | |
| 161 | // If no base can be established we fall back to the socket-embedded | |
| 162 | // snapshot so the shell still synchronizes. Overlapping/replayed events are | |
| 163 | // deduped by sequence in applyItem. | |
| 164 | const base = Option.isSome(cachedSnapshot) | |
| 165 | ? cachedSnapshot | |
| 166 | : yield* Effect.gen(function* () { | |
| 167 | const prepared = yield* SubscriptionRef.changes(supervisor.prepared).pipe( | |
| 168 | Stream.filter(Option.isSome), | |
| 169 | Stream.map((current) => current.value), | |
| 170 | Stream.runHead, | |
| 171 | ); | |
| 172 | return Option.isSome(prepared) | |
| 173 | ? yield* snapshotLoader.load(prepared.value) | |
| 174 | : Option.none<OrchestrationShellSnapshot>(); | |
| 175 | }); | |
| 172 | const foregroundResubscriptions = Option.match(wakeups, { | |
| 173 | onNone: () => Stream.never, | |
| 174 | onSome: (service) => | |
| 175 | service.changes.pipe(Stream.filter((reason) => reason === "application-active")), | |
| 176 | }); | |
| 176 | 177 | |
| 177 | if (Option.isSome(base)) { | |
| 178 | yield* applyItem({ kind: "snapshot", snapshot: base.value }); | |
| 179 | } | |
| 178 | yield* setSynchronizing; | |
| 179 | yield* Effect.forkScoped( | |
| 180 | subscribeDynamic( | |
| 181 | ORCHESTRATION_WS_METHODS.subscribeShell, | |
| 182 | Effect.fn("EnvironmentShellState.makeSubscribeInput")(function* (session) { | |
| 183 | const supportsCompletionMarker = yield* session.initialConfig.pipe( | |
| 184 | Effect.map((config) => config.shellResumeCompletionMarker === true), | |
| 185 | Effect.orElseSucceed(() => false), | |
| 186 | ); | |
| 187 | yield* Ref.set(awaitingCompletion, supportsCompletionMarker); | |
| 188 | yield* setSynchronizing; | |
| 180 | 189 | |
| 181 | const subscribeInput = Option.match(base, { | |
| 182 | onNone: () => ({}), | |
| 183 | onSome: (snapshot) => ({ afterSequence: snapshot.snapshotSequence }), | |
| 184 | }); | |
| 190 | const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( | |
| 191 | Effect.flatMap( | |
| 192 | Option.match({ | |
| 193 | onSome: Effect.succeed, | |
| 194 | onNone: () => | |
| 195 | SubscriptionRef.changes(supervisor.prepared).pipe( | |
| 196 | Stream.filter(Option.isSome), | |
| 197 | Stream.map((value) => value.value), | |
| 198 | Stream.runHead, | |
| 199 | Effect.map(Option.getOrThrow), | |
| 200 | ), | |
| 201 | }), | |
| 202 | ), | |
| 203 | ); | |
| 204 | const httpSnapshot = yield* snapshotLoader.load(prepared); | |
| 205 | if (Option.isSome(httpSnapshot)) { | |
| 206 | yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); | |
| 207 | return { | |
| 208 | afterSequence: httpSnapshot.value.snapshotSequence, | |
| 209 | ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), | |
| 210 | }; | |
| 211 | } | |
| 185 | 212 | |
| 186 | yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeShell, subscribeInput, { | |
| 213 | return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; | |
| 214 | }), | |
| 215 | { | |
| 187 | 216 | onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), |
| 188 | }).pipe(Stream.runForEach(applyItem)); | |
| 189 | }), | |
| 217 | retryExpectedFailureAfter: "250 millis", | |
| 218 | resubscribe: foregroundResubscriptions, | |
| 219 | }, | |
| 220 | ).pipe(Stream.runForEach(applyItem)), | |
| 190 | 221 | ); |
| 191 | 222 | yield* SubscriptionRef.changes(supervisor.state).pipe( |
| 192 | 223 | Stream.runForEach((connectionState) => { |
packages/client-runtime/src/state/threads-sync.test.ts+170-5| ... | ... | @@ -26,6 +26,7 @@ import { |
| 26 | 26 | type PreparedConnection, |
| 27 | 27 | type SupervisorConnectionState, |
| 28 | 28 | } from "../connection/model.ts"; |
| 29 | import * as ConnectionWakeups from "../connection/wakeups.ts"; | |
| 29 | 30 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 30 | 31 | import * as Persistence from "../platform/persistence.ts"; |
| 31 | 32 | import * as RpcSession from "../rpc/session.ts"; |
| ... | ... | @@ -98,10 +99,17 @@ const ACTIVE_THREAD: OrchestrationThread = { |
| 98 | 99 | |
| 99 | 100 | type TestThreadInput = OrchestrationThreadStreamItem | Error; |
| 100 | 101 | |
| 101 | function testSession(client: WsRpcProtocolClient): RpcSession.RpcSession { | |
| 102 | function testSession( | |
| 103 | client: WsRpcProtocolClient, | |
| 104 | options?: { readonly completionMarker?: boolean }, | |
| 105 | ): RpcSession.RpcSession { | |
| 102 | 106 | return { |
| 103 | 107 | client, |
| 104 | initialConfig: Effect.never, | |
| 108 | initialConfig: Effect.succeed( | |
| 109 | options?.completionMarker === true | |
| 110 | ? ({ threadResumeCompletionMarker: true } as never) | |
| 111 | : ({} as never), | |
| 112 | ), | |
| 105 | 113 | ready: Effect.void, |
| 106 | 114 | probe: Effect.void, |
| 107 | 115 | closed: Effect.never, |
| ... | ... | @@ -122,6 +130,7 @@ function awaitThreadState( |
| 122 | 130 | const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: { |
| 123 | 131 | readonly cached?: OrchestrationThread; |
| 124 | 132 | readonly httpSnapshot?: Option.Option<OrchestrationThreadDetailSnapshot>; |
| 133 | readonly completionMarker?: boolean; | |
| 125 | 134 | }) { |
| 126 | 135 | const inputs = yield* Queue.unbounded<TestThreadInput>(); |
| 127 | 136 | const observed = yield* Queue.unbounded<EnvironmentThreadState>(); |
| ... | ... | @@ -130,8 +139,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o |
| 130 | 139 | const subscriptionCount = yield* Ref.make(0); |
| 131 | 140 | const loaderCalls = yield* Ref.make(0); |
| 132 | 141 | const lastSubscribeAfterSequence = yield* Ref.make<number | undefined>(undefined); |
| 142 | const lastRequestCompletionMarker = yield* Ref.make<boolean | undefined>(undefined); | |
| 133 | 143 | const savedThreads = yield* Ref.make<ReadonlyArray<OrchestrationThreadDetailSnapshot>>([]); |
| 134 | 144 | const removedThreads = yield* Ref.make<ReadonlyArray<ThreadId>>([]); |
| 145 | const wakeups = yield* Queue.unbounded<ConnectionWakeups.ConnectionWakeup>(); | |
| 135 | 146 | const supervisorState = yield* SubscriptionRef.make<SupervisorConnectionState>( |
| 136 | 147 | AVAILABLE_CONNECTION_STATE, |
| 137 | 148 | ); |
| ... | ... | @@ -142,16 +153,25 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o |
| 142 | 153 | ), |
| 143 | 154 | ); |
| 144 | 155 | const client = { |
| 145 | [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { readonly afterSequence?: number }) => | |
| 156 | [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { | |
| 157 | readonly afterSequence?: number; | |
| 158 | readonly requestCompletionMarker?: boolean; | |
| 159 | }) => | |
| 146 | 160 | Stream.unwrap( |
| 147 | 161 | Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( |
| 148 | 162 | Effect.andThen(Ref.set(lastSubscribeAfterSequence, input.afterSequence)), |
| 163 | Effect.andThen(Ref.set(lastRequestCompletionMarker, input.requestCompletionMarker)), | |
| 149 | 164 | Effect.as(streamFrom(inputs)), |
| 150 | 165 | ), |
| 151 | 166 | ), |
| 152 | 167 | } as unknown as WsRpcProtocolClient; |
| 153 | 168 | const supervisorSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>( |
| 154 | Option.some(testSession(client)), | |
| 169 | Option.some( | |
| 170 | testSession( | |
| 171 | client, | |
| 172 | options?.completionMarker === true ? { completionMarker: true } : undefined, | |
| 173 | ), | |
| 174 | ), | |
| 155 | 175 | ); |
| 156 | 176 | const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>( |
| 157 | 177 | Option.some(PREPARED), |
| ... | ... | @@ -201,6 +221,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o |
| 201 | 221 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 202 | 222 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), |
| 203 | 223 | Effect.provideService(ThreadSnapshotLoader, snapshotLoader), |
| 224 | Effect.provideService( | |
| 225 | ConnectionWakeups.ConnectionWakeups, | |
| 226 | ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.fromQueue(wakeups) }), | |
| 227 | ), | |
| 204 | 228 | ); |
| 205 | 229 | yield* SubscriptionRef.changes(threadState).pipe( |
| 206 | 230 | Stream.runForEach((state) => |
| ... | ... | @@ -217,11 +241,21 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o |
| 217 | 241 | subscriptionCount, |
| 218 | 242 | loaderCalls, |
| 219 | 243 | lastSubscribeAfterSequence, |
| 244 | lastRequestCompletionMarker, | |
| 220 | 245 | supervisorState, |
| 221 | 246 | supervisorSession, |
| 222 | 247 | savedThreads, |
| 223 | 248 | removedThreads, |
| 224 | replaceSession: SubscriptionRef.set(supervisorSession, Option.some(testSession(client))), | |
| 249 | wakeups, | |
| 250 | replaceSession: SubscriptionRef.set( | |
| 251 | supervisorSession, | |
| 252 | Option.some( | |
| 253 | testSession( | |
| 254 | client, | |
| 255 | options?.completionMarker === true ? { completionMarker: true } : undefined, | |
| 256 | ), | |
| 257 | ), | |
| 258 | ), | |
| 225 | 259 | }; |
| 226 | 260 | }); |
| 227 | 261 | |
| ... | ... | @@ -233,6 +267,8 @@ const snapshot = (thread: OrchestrationThread): OrchestrationThreadStreamItem => |
| 233 | 267 | }, |
| 234 | 268 | }); |
| 235 | 269 | |
| 270 | const synchronized = (): OrchestrationThreadStreamItem => ({ kind: "synchronized" }); | |
| 271 | ||
| 236 | 272 | const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamItem => ({ |
| 237 | 273 | kind: "event", |
| 238 | 274 | event: { |
| ... | ... | @@ -415,6 +451,35 @@ describe("EnvironmentThreads", () => { |
| 415 | 451 | }), |
| 416 | 452 | ); |
| 417 | 453 | |
| 454 | it.effect("does not resurrect a deleted thread when the app returns to the foreground", () => | |
| 455 | Effect.gen(function* () { | |
| 456 | const harness = yield* makeHarness({ | |
| 457 | cached: BASE_THREAD, | |
| 458 | completionMarker: true, | |
| 459 | httpSnapshot: Option.some({ | |
| 460 | snapshotSequence: 4, | |
| 461 | thread: { ...BASE_THREAD, title: "Stale HTTP thread" }, | |
| 462 | }), | |
| 463 | }); | |
| 464 | yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); | |
| 465 | yield* Queue.offer(harness.inputs, deleted()); | |
| 466 | yield* awaitThreadState(harness.observed, (value) => value.status === "deleted"); | |
| 467 | ||
| 468 | expect(yield* Ref.get(harness.loaderCalls)).toBe(0); | |
| 469 | yield* Queue.offer(harness.wakeups, "application-active"); | |
| 470 | for (let attempt = 0; attempt < 100; attempt += 1) { | |
| 471 | if ((yield* Ref.get(harness.subscriptionCount)) >= 2) break; | |
| 472 | yield* Effect.yieldNow; | |
| 473 | } | |
| 474 | ||
| 475 | const latest = yield* Ref.get(harness.latest); | |
| 476 | expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); | |
| 477 | expect(yield* Ref.get(harness.loaderCalls)).toBe(0); | |
| 478 | expect(latest.status).toBe("deleted"); | |
| 479 | expect(Option.isNone(latest.data)).toBe(true); | |
| 480 | }), | |
| 481 | ); | |
| 482 | ||
| 418 | 483 | it.effect("preserves data after a domain failure and resumes on a replacement session", () => |
| 419 | 484 | Effect.gen(function* () { |
| 420 | 485 | const harness = yield* makeHarness({ cached: BASE_THREAD }); |
| ... | ... | @@ -529,4 +594,104 @@ describe("EnvironmentThreads", () => { |
| 529 | 594 | expect((yield* Ref.get(harness.latest)).status).toBe("live"); |
| 530 | 595 | }), |
| 531 | 596 | ); |
| 597 | ||
| 598 | it.effect("keeps replayed updates synchronizing until the completion marker arrives", () => | |
| 599 | Effect.gen(function* () { | |
| 600 | const harness = yield* makeHarness({ cached: BASE_THREAD, completionMarker: true }); | |
| 601 | yield* awaitThreadState( | |
| 602 | harness.observed, | |
| 603 | (value) => value.status === "synchronizing" && Option.isSome(value.data), | |
| 604 | ); | |
| 605 | expect(yield* Ref.get(harness.lastRequestCompletionMarker)).toBe(true); | |
| 606 | ||
| 607 | yield* Queue.offer( | |
| 608 | harness.inputs, | |
| 609 | titleUpdated("Caught-up title", CACHED_SNAPSHOT_SEQUENCE + 1), | |
| 610 | ); | |
| 611 | const catchingUp = yield* awaitThreadState( | |
| 612 | harness.observed, | |
| 613 | (value) => | |
| 614 | value.status === "synchronizing" && | |
| 615 | Option.isSome(value.data) && | |
| 616 | value.data.value.title === "Caught-up title", | |
| 617 | ); | |
| 618 | expect(catchingUp.status).toBe("synchronizing"); | |
| 619 | ||
| 620 | yield* Queue.offer(harness.inputs, synchronized()); | |
| 621 | const live = yield* awaitThreadState( | |
| 622 | harness.observed, | |
| 623 | (value) => value.status === "live" && Option.isSome(value.data), | |
| 624 | ); | |
| 625 | expect(Option.getOrThrow(live.data).title).toBe("Caught-up title"); | |
| 626 | }), | |
| 627 | ); | |
| 628 | ||
| 629 | it.effect("resumes replacement sessions from the latest applied sequence", () => | |
| 630 | Effect.gen(function* () { | |
| 631 | const harness = yield* makeHarness({ cached: BASE_THREAD, completionMarker: true }); | |
| 632 | yield* Queue.offer( | |
| 633 | harness.inputs, | |
| 634 | titleUpdated("Latest title", CACHED_SNAPSHOT_SEQUENCE + 1), | |
| 635 | ); | |
| 636 | yield* Queue.offer(harness.inputs, synchronized()); | |
| 637 | yield* awaitThreadState( | |
| 638 | harness.observed, | |
| 639 | (value) => | |
| 640 | value.status === "live" && | |
| 641 | Option.isSome(value.data) && | |
| 642 | value.data.value.title === "Latest title", | |
| 643 | ); | |
| 644 | ||
| 645 | yield* harness.replaceSession; | |
| 646 | for (let attempt = 0; attempt < 100; attempt += 1) { | |
| 647 | if ((yield* Ref.get(harness.subscriptionCount)) >= 2) break; | |
| 648 | yield* Effect.yieldNow; | |
| 649 | } | |
| 650 | ||
| 651 | expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); | |
| 652 | expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE + 1); | |
| 653 | expect((yield* Ref.get(harness.latest)).status).toBe("synchronizing"); | |
| 654 | }), | |
| 655 | ); | |
| 656 | ||
| 657 | it.effect("resubscribes on app foreground from the latest applied sequence", () => | |
| 658 | Effect.gen(function* () { | |
| 659 | const harness = yield* makeHarness({ cached: BASE_THREAD, completionMarker: true }); | |
| 660 | yield* Queue.offer( | |
| 661 | harness.inputs, | |
| 662 | titleUpdated("Latest title", CACHED_SNAPSHOT_SEQUENCE + 1), | |
| 663 | ); | |
| 664 | yield* Queue.offer(harness.inputs, synchronized()); | |
| 665 | yield* awaitThreadState( | |
| 666 | harness.observed, | |
| 667 | (value) => | |
| 668 | value.status === "live" && | |
| 669 | Option.isSome(value.data) && | |
| 670 | value.data.value.title === "Latest title", | |
| 671 | ); | |
| 672 | ||
| 673 | yield* Queue.offer(harness.wakeups, "application-active"); | |
| 674 | const synchronizing = yield* awaitThreadState( | |
| 675 | harness.observed, | |
| 676 | (value) => value.status === "synchronizing" && Option.isSome(value.data), | |
| 677 | ); | |
| 678 | for (let attempt = 0; attempt < 100; attempt += 1) { | |
| 679 | if ((yield* Ref.get(harness.subscriptionCount)) >= 2) break; | |
| 680 | yield* Effect.yieldNow; | |
| 681 | } | |
| 682 | ||
| 683 | expect(synchronizing.status).toBe("synchronizing"); | |
| 684 | expect(yield* Ref.get(harness.subscriptionCount)).toBe(2); | |
| 685 | expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE + 1); | |
| 686 | expect(yield* Ref.get(harness.lastRequestCompletionMarker)).toBe(true); | |
| 687 | expect(yield* Ref.get(harness.loaderCalls)).toBe(0); | |
| 688 | ||
| 689 | yield* Queue.offer(harness.inputs, synchronized()); | |
| 690 | const live = yield* awaitThreadState( | |
| 691 | harness.observed, | |
| 692 | (value) => value.status === "live" && Option.isSome(value.data), | |
| 693 | ); | |
| 694 | expect(Option.getOrThrow(live.data).title).toBe("Latest title"); | |
| 695 | }), | |
| 696 | ); | |
| 532 | 697 | }); |
packages/client-runtime/src/state/threads.ts+98-49| ... | ... | @@ -10,6 +10,7 @@ import * as Cause from "effect/Cause"; |
| 10 | 10 | import * as Effect from "effect/Effect"; |
| 11 | 11 | import * as Option from "effect/Option"; |
| 12 | 12 | import * as Queue from "effect/Queue"; |
| 13 | import * as Ref from "effect/Ref"; | |
| 13 | 14 | import * as Stream from "effect/Stream"; |
| 14 | 15 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 15 | 16 | import { Atom } from "effect/unstable/reactivity"; |
| ... | ... | @@ -17,8 +18,9 @@ import { Atom } from "effect/unstable/reactivity"; |
| 17 | 18 | import { EnvironmentRegistry } from "../connection/registry.ts"; |
| 18 | 19 | import { connectionProjectionPhase } from "../connection/model.ts"; |
| 19 | 20 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; |
| 21 | import * as ConnectionWakeups from "../connection/wakeups.ts"; | |
| 20 | 22 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; |
| 21 | import { subscribe } from "../rpc/client.ts"; | |
| 23 | import { subscribeDynamic } from "../rpc/client.ts"; | |
| 22 | 24 | import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; |
| 23 | 25 | import { parseThreadKey, threadKey } from "./entities.ts"; |
| 24 | 26 | import { applyThreadDetailEvent } from "./threadReducer.ts"; |
| ... | ... | @@ -52,6 +54,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 52 | 54 | const supervisor = yield* EnvironmentSupervisor; |
| 53 | 55 | const cache = yield* EnvironmentCacheStore; |
| 54 | 56 | const snapshotLoader = yield* ThreadSnapshotLoader; |
| 57 | const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); | |
| 55 | 58 | const environmentId = supervisor.target.environmentId; |
| 56 | 59 | const cached = yield* cache.loadThread(environmentId, threadId).pipe( |
| 57 | 60 | Effect.catch((error) => |
| ... | ... | @@ -76,6 +79,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 76 | 79 | const lastSequence = yield* SubscriptionRef.make( |
| 77 | 80 | Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), |
| 78 | 81 | ); |
| 82 | const awaitingCompletion = yield* Ref.make(false); | |
| 79 | 83 | const persistence = yield* Queue.sliding<OrchestrationThreadDetailSnapshot>(1); |
| 80 | 84 | |
| 81 | 85 | const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( |
| ... | ... | @@ -100,11 +104,15 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 100 | 104 | Effect.forkScoped, |
| 101 | 105 | ); |
| 102 | 106 | |
| 103 | const setSynchronizing = SubscriptionRef.update(state, (current) => ({ | |
| 104 | ...current, | |
| 105 | status: "synchronizing" as const, | |
| 106 | error: Option.none(), | |
| 107 | })); | |
| 107 | const setSynchronizing = SubscriptionRef.update(state, (current) => | |
| 108 | current.status === "deleted" | |
| 109 | ? current | |
| 110 | : { | |
| 111 | ...current, | |
| 112 | status: "synchronizing" as const, | |
| 113 | error: Option.none(), | |
| 114 | }, | |
| 115 | ); | |
| 108 | 116 | const setReady = SubscriptionRef.update(state, (current) => |
| 109 | 117 | current.status === "live" || current.status === "deleted" |
| 110 | 118 | ? current |
| ... | ... | @@ -114,23 +122,32 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 114 | 122 | error: Option.none(), |
| 115 | 123 | }, |
| 116 | 124 | ); |
| 117 | const setDisconnected = SubscriptionRef.update(state, (current) => ({ | |
| 118 | ...current, | |
| 119 | status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), | |
| 120 | })); | |
| 121 | const setStreamError = (cause: Cause.Cause<unknown>) => | |
| 122 | SubscriptionRef.update(state, (current) => ({ | |
| 125 | const setDisconnected = Effect.gen(function* () { | |
| 126 | yield* Ref.set(awaitingCompletion, false); | |
| 127 | yield* SubscriptionRef.update(state, (current) => ({ | |
| 123 | 128 | ...current, |
| 124 | 129 | status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), |
| 125 | error: Option.some(formatThreadError(cause)), | |
| 126 | 130 | })); |
| 131 | }); | |
| 132 | const setStreamError = (cause: Cause.Cause<unknown>) => | |
| 133 | Ref.set(awaitingCompletion, false).pipe( | |
| 134 | Effect.andThen( | |
| 135 | SubscriptionRef.update(state, (current) => ({ | |
| 136 | ...current, | |
| 137 | status: | |
| 138 | current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), | |
| 139 | error: Option.some(formatThreadError(cause)), | |
| 140 | })), | |
| 141 | ), | |
| 142 | ); | |
| 127 | 143 | |
| 128 | 144 | const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( |
| 129 | 145 | thread: OrchestrationThread, |
| 130 | 146 | ) { |
| 147 | const waiting = yield* Ref.get(awaitingCompletion); | |
| 131 | 148 | yield* SubscriptionRef.set(state, { |
| 132 | 149 | data: Option.some(thread), |
| 133 | status: "live", | |
| 150 | status: waiting ? "synchronizing" : "live", | |
| 134 | 151 | error: Option.none(), |
| 135 | 152 | }); |
| 136 | 153 | // Active threads can update many times per second and retain large tool |
| ... | ... | @@ -143,6 +160,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 143 | 160 | }); |
| 144 | 161 | |
| 145 | 162 | const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { |
| 163 | yield* Ref.set(awaitingCompletion, false); | |
| 146 | 164 | yield* SubscriptionRef.set(state, { |
| 147 | 165 | data: Option.none(), |
| 148 | 166 | status: "deleted", |
| ... | ... | @@ -164,6 +182,16 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 164 | 182 | const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( |
| 165 | 183 | item: OrchestrationThreadStreamItem, |
| 166 | 184 | ) { |
| 185 | if (item.kind === "synchronized") { | |
| 186 | yield* Ref.set(awaitingCompletion, false); | |
| 187 | yield* SubscriptionRef.update(state, (current) => | |
| 188 | Option.isSome(current.data) && current.status !== "deleted" | |
| 189 | ? { ...current, status: "live" as const, error: Option.none() } | |
| 190 | : current, | |
| 191 | ); | |
| 192 | return; | |
| 193 | } | |
| 194 | ||
| 167 | 195 | if (item.kind === "snapshot") { |
| 168 | 196 | yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); |
| 169 | 197 | yield* setThread(item.snapshot.thread); |
| ... | ... | @@ -205,48 +233,69 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 205 | 233 | Effect.forkScoped, |
| 206 | 234 | ); |
| 207 | 235 | |
| 236 | const foregroundResubscriptions = Option.match(wakeups, { | |
| 237 | onNone: () => Stream.never, | |
| 238 | onSome: (service) => | |
| 239 | service.changes.pipe(Stream.filter((reason) => reason === "application-active")), | |
| 240 | }); | |
| 241 | ||
| 208 | 242 | yield* setSynchronizing; |
| 209 | 243 | yield* Effect.forkScoped( |
| 210 | Effect.gen(function* () { | |
| 211 | // Establish the base snapshot to resume from, minimizing bytes over the | |
| 212 | // wire: | |
| 213 | // - Warm cache: reuse the cached snapshot (zero network) and resume via | |
| 214 | // `afterSequence` so we only receive events since the cached sequence. | |
| 215 | // - Cold cache: load the full snapshot over HTTP (gzip-compressible, and | |
| 216 | // off the socket), then resume via `afterSequence`. | |
| 217 | // If no base can be established we fall back to the socket-embedded | |
| 218 | // snapshot so the thread still synchronizes. Overlapping/replayed events | |
| 219 | // are deduped by sequence in applyItem. | |
| 220 | const base = Option.isSome(cached) | |
| 221 | ? cached | |
| 222 | : yield* Effect.gen(function* () { | |
| 223 | // Cold cache only: wait for a prepared connection so we can | |
| 224 | // authenticate the HTTP request; this mirrors the socket path, which | |
| 225 | // likewise waits for a live session. | |
| 226 | const prepared = yield* SubscriptionRef.changes(supervisor.prepared).pipe( | |
| 227 | Stream.filter(Option.isSome), | |
| 228 | Stream.map((current) => current.value), | |
| 229 | Stream.runHead, | |
| 230 | ); | |
| 231 | return Option.isSome(prepared) | |
| 232 | ? yield* snapshotLoader.load(prepared.value, threadId) | |
| 233 | : Option.none<OrchestrationThreadDetailSnapshot>(); | |
| 234 | }); | |
| 244 | subscribeDynamic( | |
| 245 | ORCHESTRATION_WS_METHODS.subscribeThread, | |
| 246 | Effect.fn("EnvironmentThreadState.makeSubscribeInput")(function* (session) { | |
| 247 | const supportsCompletionMarker = yield* session.initialConfig.pipe( | |
| 248 | Effect.map((config) => config.threadResumeCompletionMarker === true), | |
| 249 | Effect.orElseSucceed(() => false), | |
| 250 | ); | |
| 251 | yield* Ref.set(awaitingCompletion, supportsCompletionMarker); | |
| 252 | yield* setSynchronizing; | |
| 235 | 253 | |
| 236 | if (Option.isSome(base)) { | |
| 237 | yield* applyItem({ kind: "snapshot", snapshot: base.value }); | |
| 238 | } | |
| 254 | let current = yield* SubscriptionRef.get(state); | |
| 255 | if (Option.isNone(current.data) && current.status !== "deleted") { | |
| 256 | const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( | |
| 257 | Effect.flatMap( | |
| 258 | Option.match({ | |
| 259 | onSome: Effect.succeed, | |
| 260 | onNone: () => | |
| 261 | SubscriptionRef.changes(supervisor.prepared).pipe( | |
| 262 | Stream.filter(Option.isSome), | |
| 263 | Stream.map((value) => value.value), | |
| 264 | Stream.runHead, | |
| 265 | Effect.map(Option.getOrThrow), | |
| 266 | ), | |
| 267 | }), | |
| 268 | ), | |
| 269 | ); | |
| 270 | const httpSnapshot = yield* snapshotLoader.load(prepared, threadId); | |
| 271 | if (Option.isSome(httpSnapshot)) { | |
| 272 | yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); | |
| 273 | current = yield* SubscriptionRef.get(state); | |
| 274 | } | |
| 275 | } | |
| 239 | 276 | |
| 240 | const subscribeInput = Option.match(base, { | |
| 241 | onNone: () => ({ threadId }), | |
| 242 | onSome: (snapshot) => ({ threadId, afterSequence: snapshot.snapshotSequence }), | |
| 243 | }); | |
| 277 | const sequence = yield* SubscriptionRef.get(lastSequence); | |
| 278 | const canResume = Option.isSome(current.data); | |
| 279 | if (!supportsCompletionMarker && canResume) { | |
| 280 | yield* SubscriptionRef.update(state, (value) => ({ | |
| 281 | ...value, | |
| 282 | status: value.status === "deleted" ? value.status : ("live" as const), | |
| 283 | error: Option.none(), | |
| 284 | })); | |
| 285 | } | |
| 244 | 286 | |
| 245 | yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeThread, subscribeInput, { | |
| 287 | return { | |
| 288 | threadId, | |
| 289 | ...(canResume ? { afterSequence: sequence } : {}), | |
| 290 | ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), | |
| 291 | }; | |
| 292 | }), | |
| 293 | { | |
| 246 | 294 | onExpectedFailure: setStreamError, |
| 247 | 295 | retryExpectedFailureAfter: "250 millis", |
| 248 | }).pipe(Stream.runForEach(applyItem)); | |
| 249 | }), | |
| 296 | resubscribe: foregroundResubscriptions, | |
| 297 | }, | |
| 298 | ).pipe(Stream.runForEach(applyItem)), | |
| 250 | 299 | ); |
| 251 | 300 | |
| 252 | 301 | yield* Effect.addFinalizer(() => |
packages/contracts/src/orchestration.ts+16| ... | ... | @@ -443,6 +443,9 @@ export const OrchestrationShellStreamEvent = Schema.Union([ |
| 443 | 443 | export type OrchestrationShellStreamEvent = typeof OrchestrationShellStreamEvent.Type; |
| 444 | 444 | |
| 445 | 445 | export const OrchestrationShellStreamItem = Schema.Union([ |
| 446 | Schema.Struct({ | |
| 447 | kind: Schema.Literal("synchronized"), | |
| 448 | }), | |
| 446 | 449 | Schema.Struct({ |
| 447 | 450 | kind: Schema.Literal("snapshot"), |
| 448 | 451 | snapshot: OrchestrationShellSnapshot, |
| ... | ... | @@ -461,6 +464,11 @@ export const OrchestrationSubscribeShellInput = Schema.Struct({ |
| 461 | 464 | * client). |
| 462 | 465 | */ |
| 463 | 466 | afterSequence: Schema.optionalKey(NonNegativeInt), |
| 467 | /** | |
| 468 | * Requests an explicit marker after the subscription has emitted its initial | |
| 469 | * snapshot or catch-up replay and before it begins emitting live events. | |
| 470 | */ | |
| 471 | requestCompletionMarker: Schema.optionalKey(Schema.Boolean), | |
| 464 | 472 | }); |
| 465 | 473 | export type OrchestrationSubscribeShellInput = typeof OrchestrationSubscribeShellInput.Type; |
| 466 | 474 | |
| ... | ... | @@ -474,6 +482,11 @@ export const OrchestrationSubscribeThreadInput = Schema.Struct({ |
| 474 | 482 | * sequence on the client). |
| 475 | 483 | */ |
| 476 | 484 | afterSequence: Schema.optionalKey(NonNegativeInt), |
| 485 | /** | |
| 486 | * Requests an explicit marker after the subscription has emitted its initial | |
| 487 | * snapshot or catch-up replay and before it begins emitting live events. | |
| 488 | */ | |
| 489 | requestCompletionMarker: Schema.optionalKey(Schema.Boolean), | |
| 477 | 490 | }); |
| 478 | 491 | export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type; |
| 479 | 492 | |
| ... | ... | @@ -1135,6 +1148,9 @@ export const OrchestrationEvent = Schema.Union([ |
| 1135 | 1148 | export type OrchestrationEvent = typeof OrchestrationEvent.Type; |
| 1136 | 1149 | |
| 1137 | 1150 | export const OrchestrationThreadStreamItem = Schema.Union([ |
| 1151 | Schema.Struct({ | |
| 1152 | kind: Schema.Literal("synchronized"), | |
| 1153 | }), | |
| 1138 | 1154 | Schema.Struct({ |
| 1139 | 1155 | kind: Schema.Literal("snapshot"), |
| 1140 | 1156 | snapshot: OrchestrationThreadDetailSnapshot, |
packages/contracts/src/server.ts+4| ... | ... | @@ -417,6 +417,10 @@ export const ServerConfig = Schema.Struct({ |
| 417 | 417 | availableEditors: Schema.Array(EditorId), |
| 418 | 418 | observability: ServerObservability, |
| 419 | 419 | settings: ServerSettings, |
| 420 | /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ | |
| 421 | shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), | |
| 422 | /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */ | |
| 423 | threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), | |
| 420 | 424 | }); |
| 421 | 425 | export type ServerConfig = typeof ServerConfig.Type; |
| 422 | 426 |