| author | |
| committer | |
| log | b883fc066ea5c9bebbe1c3e9b4bc2471aab3685f |
| tree | 169b7fd375306cf1d71438fbd15eb3b227e95a34 |
| parent | e86604d3372acccd9f6a33a2c4ae46f4e2685541 |
| signature | Signed by PGP key B5690EEEBB952194 |
Reuse one server config subscription for session bootstrap and live updates.
Preserve environment theme opt-in, replay, deletion, slow subscriber recovery, and config stream failure handling.
Co-authored-by: Adamulek123 <adam.bogucki2018@gmail.com>20 files changed, 1053 insertions(+), 151 deletions(-)
apps/web/src/cloud/linkEnvironment.test.ts+1| ... | @@ -91,6 +91,7 @@ function registryLayer(options?: { | ... | @@ -91,6 +91,7 @@ function registryLayer(options?: { |
| 91 | const session: RpcSession = { | 91 | const session: RpcSession = { |
| 92 | client, | 92 | client, |
| 93 | initialConfig: Effect.never, | 93 | initialConfig: Effect.never, |
| 94 | subscribeServerConfig: (input) => client.subscribeServerConfig(input), | ||
| 94 | ready: Effect.void, | 95 | ready: Effect.void, |
| 95 | probe: Effect.void, | 96 | probe: Effect.void, |
| 96 | closed: Effect.never, | 97 | closed: Effect.never, |
apps/web/src/connection/runtime.ts+4-1| ... | @@ -30,7 +30,10 @@ type ConnectionLayerSource = | ... | @@ -30,7 +30,10 @@ type ConnectionLayerSource = |
| 30 | | typeof backgroundActivityObserverLayer | 30 | | typeof backgroundActivityObserverLayer |
| 31 | | typeof backgroundActivityReporterLayer; | 31 | | typeof backgroundActivityReporterLayer; |
| 32 | 32 | ||
| 33 | const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( | 33 | const providedClientConnectionLayer = Layer.merge( |
| 34 | Connection.layerWithOptions({ environmentThemes: true }), | ||
| 35 | snapshotLoaderLayer, | ||
| 36 | ).pipe( | ||
| 34 | Layer.provideMerge( | 37 | Layer.provideMerge( |
| 35 | Layer.mergeAll( | 38 | Layer.mergeAll( |
| 36 | runtimeContextLayer, | 39 | runtimeContextLayer, |
packages/client-runtime/src/connection/layer.ts+26-27| ... | @@ -15,30 +15,29 @@ const resolverLayer = ConnectionResolver.layer.pipe( | ... | @@ -15,30 +15,29 @@ const resolverLayer = ConnectionResolver.layer.pipe( |
| 15 | Layer.provide(RemoteEnvironmentAuthorization.layer), | 15 | Layer.provide(RemoteEnvironmentAuthorization.layer), |
| 16 | ); | 16 | ); |
| 17 | 17 | ||
| 18 | const driverLayer = ConnectionDriver.layer.pipe( | 18 | export function layerWithOptions(options: RpcSession.RpcSessionOptions) { |
| 19 | Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layer)), | 19 | const driverLayer = ConnectionDriver.layer.pipe( |
| 20 | ); | 20 | Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layerWithOptions(options))), |
| 21 | 21 | ); | |
| 22 | const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); | 22 | const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); |
| 23 | 23 | const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); | |
| 24 | const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); | 24 | const connectionServicesLayer = Layer.mergeAll( |
| 25 | 25 | registryLayer, | |
| 26 | const connectionServicesLayer = Layer.mergeAll( | 26 | RelayEnvironmentDiscovery.layer, |
| 27 | registryLayer, | 27 | onboardingLayer, |
| 28 | RelayEnvironmentDiscovery.layer, | 28 | ); |
| 29 | onboardingLayer, | 29 | const connectionStartupLayer = Layer.effectDiscard( |
| 30 | ); | 30 | Effect.gen(function* () { |
| 31 | 31 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | |
| 32 | const connectionStartupLayer = Layer.effectDiscard( | 32 | const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; |
| 33 | Effect.gen(function* () { | 33 | yield* registry.start; |
| 34 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | 34 | yield* platformSource.registrations.pipe( |
| 35 | const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; | 35 | Stream.runForEach(registry.reconcilePlatform), |
| 36 | yield* registry.start; | 36 | Effect.forkScoped, |
| 37 | yield* platformSource.registrations.pipe( | 37 | ); |
| 38 | Stream.runForEach(registry.reconcilePlatform), | 38 | }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), |
| 39 | Effect.forkScoped, | 39 | ); |
| 40 | ); | 40 | return connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer)); |
| 41 | }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), | 41 | } |
| 42 | ); | 42 | |
| 43 | 43 | export const layer = layerWithOptions({}); | |
| 44 | export const layer = connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer)); |
packages/client-runtime/src/connection/registry.test.ts+2| ... | @@ -356,6 +356,8 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( | ... | @@ -356,6 +356,8 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 356 | Effect.succeed({ | 356 | Effect.succeed({ |
| 357 | client: {} as RpcSession.RpcSession["client"], | 357 | client: {} as RpcSession.RpcSession["client"], |
| 358 | initialConfig: Effect.die(new Error("Config is not used by registry tests.")), | 358 | initialConfig: Effect.die(new Error("Config is not used by registry tests.")), |
| 359 | subscribeServerConfig: () => | ||
| 360 | Stream.die(new Error("Config is not used by registry tests.")), | ||
| 359 | ready: Effect.void, | 361 | ready: Effect.void, |
| 360 | probe: Effect.void, | 362 | probe: Effect.void, |
| 361 | closed: Deferred.await(closed), | 363 | closed: Deferred.await(closed), |
packages/client-runtime/src/connection/supervisor.test.ts+1| ... | @@ -163,6 +163,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: | ... | @@ -163,6 +163,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: |
| 163 | Effect.succeed({ | 163 | Effect.succeed({ |
| 164 | client: TEST_RPC_CLIENT, | 164 | client: TEST_RPC_CLIENT, |
| 165 | initialConfig: Effect.die(new Error("Initial config is not used by supervisor tests.")), | 165 | initialConfig: Effect.die(new Error("Initial config is not used by supervisor tests.")), |
| 166 | subscribeServerConfig: (input) => TEST_RPC_CLIENT.subscribeServerConfig(input), | ||
| 166 | ready: options?.ready?.(attempt) ?? Effect.void, | 167 | ready: options?.ready?.(attempt) ?? Effect.void, |
| 167 | probe: options?.probe?.(attempt) ?? Effect.void, | 168 | probe: options?.probe?.(attempt) ?? Effect.void, |
| 168 | closed: Deferred.await(closed), | 169 | closed: Deferred.await(closed), |
packages/client-runtime/src/operations/commands.test.ts+1| ... | @@ -57,6 +57,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct | ... | @@ -57,6 +57,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct |
| 57 | const session: RpcSession.RpcSession = { | 57 | const session: RpcSession.RpcSession = { |
| 58 | client, | 58 | client, |
| 59 | initialConfig: Effect.never, | 59 | initialConfig: Effect.never, |
| 60 | subscribeServerConfig: (input) => client.subscribeServerConfig(input), | ||
| 60 | ready: Effect.void, | 61 | ready: Effect.void, |
| 61 | probe: Effect.void, | 62 | probe: Effect.void, |
| 62 | closed: Effect.never, | 63 | closed: Effect.never, |
packages/client-runtime/src/rpc/client.test.ts+36| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_SERVER_SETTINGS, | ||
| 2 | EnvironmentId, | 3 | EnvironmentId, |
| 3 | type RelayClientInstallProgressEvent, | 4 | type RelayClientInstallProgressEvent, |
| 5 | type ServerConfigStreamEvent, | ||
| 4 | WS_METHODS, | 6 | WS_METHODS, |
| 5 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 6 | import { describe, expect, it } from "@effect/vitest"; | 8 | import { describe, expect, it } from "@effect/vitest"; |
| ... | @@ -47,6 +49,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { | ... | @@ -47,6 +49,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { |
| 47 | return { | 49 | return { |
| 48 | client, | 50 | client, |
| 49 | initialConfig: Effect.never, | 51 | initialConfig: Effect.never, |
| 52 | subscribeServerConfig: (input) => client.subscribeServerConfig(input), | ||
| 50 | ready: Effect.void, | 53 | ready: Effect.void, |
| 51 | probe: Effect.void, | 54 | probe: Effect.void, |
| 52 | closed: Effect.never, | 55 | closed: Effect.never, |
| ... | @@ -77,6 +80,39 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { | ... | @@ -77,6 +80,39 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { |
| 77 | }); | 80 | }); |
| 78 | 81 | ||
| 79 | describe("environment RPC", () => { | 82 | describe("environment RPC", () => { |
| 83 | it.effect("reuses the session config stream instead of opening a duplicate subscription", () => | ||
| 84 | Effect.gen(function* () { | ||
| 85 | const event: ServerConfigStreamEvent = { | ||
| 86 | version: 1, | ||
| 87 | type: "settingsUpdated", | ||
| 88 | payload: { settings: DEFAULT_SERVER_SETTINGS }, | ||
| 89 | }; | ||
| 90 | let duplicateSubscriptions = 0; | ||
| 91 | const client = { | ||
| 92 | [WS_METHODS.subscribeServerConfig]: () => { | ||
| 93 | duplicateSubscriptions += 1; | ||
| 94 | return Stream.never; | ||
| 95 | }, | ||
| 96 | } as unknown as WsRpcProtocolClient; | ||
| 97 | const { activeSession, supervisor } = yield* makeHarness(); | ||
| 98 | yield* SubscriptionRef.set( | ||
| 99 | activeSession, | ||
| 100 | Option.some({ | ||
| 101 | ...session(client), | ||
| 102 | subscribeServerConfig: () => Stream.succeed(event), | ||
| 103 | }), | ||
| 104 | ); | ||
| 105 | |||
| 106 | const received = yield* subscribe(WS_METHODS.subscribeServerConfig, {}).pipe( | ||
| 107 | Stream.runHead, | ||
| 108 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 109 | ); | ||
| 110 | |||
| 111 | expect(received).toEqual(Option.some(event)); | ||
| 112 | expect(duplicateSubscriptions).toBe(0); | ||
| 113 | }), | ||
| 114 | ); | ||
| 115 | |||
| 80 | it.effect("observes unary requests until they complete", () => | 116 | it.effect("observes unary requests until they complete", () => |
| 81 | Effect.gen(function* () { | 117 | Effect.gen(function* () { |
| 82 | const observations: string[] = []; | 118 | const observations: string[] = []; |
packages/client-runtime/src/rpc/client.ts+5-1| ... | @@ -203,7 +203,11 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>( | ... | @@ -203,7 +203,11 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>( |
| 203 | Option.match({ | 203 | Option.match({ |
| 204 | onNone: () => Stream.empty, | 204 | onNone: () => Stream.empty, |
| 205 | onSome: (session) => { | 205 | onSome: (session) => { |
| 206 | const method = session.client[tag] as ( | 206 | const method = ( |
| 207 | tag === WS_METHODS.subscribeServerConfig | ||
| 208 | ? session.subscribeServerConfig | ||
| 209 | : session.client[tag] | ||
| 210 | ) as ( | ||
| 207 | input: EnvironmentRpcInput<TTag>, | 211 | input: EnvironmentRpcInput<TTag>, |
| 208 | ) => Stream.Stream< | 212 | ) => Stream.Stream< |
| 209 | EnvironmentRpcStreamValue<TTag>, | 213 | EnvironmentRpcStreamValue<TTag>, |
packages/client-runtime/src/rpc/session.test.ts+675-23| ... | @@ -1,24 +1,41 @@ | ... | @@ -1,24 +1,41 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_SERVER_SETTINGS, | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | ||
| 4 | ServerConfig, | 6 | ServerConfig, |
| 5 | type ServerConfig as ServerConfigType, | 7 | type ServerConfig as ServerConfigType, |
| 8 | ServerConfigStreamEvent, | ||
| 9 | type ServerConfigStreamEvent as ServerConfigStreamEventType, | ||
| 6 | WS_METHODS, | 10 | WS_METHODS, |
| 7 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 8 | import { describe, expect, it } from "@effect/vitest"; | 12 | import { describe, expect, it } from "@effect/vitest"; |
| 13 | import * as Cause from "effect/Cause"; | ||
| 14 | import * as Deferred from "effect/Deferred"; | ||
| 9 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as Exit from "effect/Exit"; | ||
| 10 | import * as Fiber from "effect/Fiber"; | 17 | import * as Fiber from "effect/Fiber"; |
| 11 | import * as Layer from "effect/Layer"; | 18 | import * as Layer from "effect/Layer"; |
| 19 | import * as Option from "effect/Option"; | ||
| 20 | import * as Queue from "effect/Queue"; | ||
| 12 | import * as Schema from "effect/Schema"; | 21 | import * as Schema from "effect/Schema"; |
| 22 | import * as Stream from "effect/Stream"; | ||
| 23 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 13 | import * as TestClock from "effect/testing/TestClock"; | 24 | import * as TestClock from "effect/testing/TestClock"; |
| 14 | import * as Socket from "effect/unstable/socket/Socket"; | 25 | import * as Socket from "effect/unstable/socket/Socket"; |
| 15 | 26 | ||
| 16 | import { | 27 | import { |
| 28 | AVAILABLE_CONNECTION_STATE, | ||
| 29 | ConnectionBlockedError, | ||
| 17 | ConnectionTransientError, | 30 | ConnectionTransientError, |
| 18 | PrimaryConnectionTarget, | 31 | PrimaryConnectionTarget, |
| 19 | type PreparedConnection, | 32 | type PreparedConnection, |
| 20 | } from "../connection/model.ts"; | 33 | } from "../connection/model.ts"; |
| 34 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | ||
| 35 | import * as Persistence from "../platform/persistence.ts"; | ||
| 21 | import * as RpcSession from "./session.ts"; | 36 | import * as RpcSession from "./session.ts"; |
| 37 | import { makeEnvironmentServerConfigState } from "../state/server.ts"; | ||
| 38 | import { applyServerConfigProjection } from "../state/serverConfigProjection.ts"; | ||
| 22 | 39 | ||
| 23 | type SocketEventType = "open" | "message" | "close" | "error"; | 40 | type SocketEventType = "open" | "message" | "close" | "error"; |
| 24 | type SocketEvent = { | 41 | type SocketEvent = { |
| ... | @@ -139,10 +156,24 @@ const RpcRequest = Schema.TaggedStruct("Request", { | ... | @@ -139,10 +156,24 @@ const RpcRequest = Schema.TaggedStruct("Request", { |
| 139 | tag: Schema.String, | 156 | tag: Schema.String, |
| 140 | }); | 157 | }); |
| 141 | const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | 158 | const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); |
| 142 | const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest); | 159 | const isRpcRequest = Schema.is(RpcRequest); |
| 160 | const isPing = Schema.is(Schema.Struct({ _tag: Schema.Literal("Ping") })); | ||
| 143 | const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | 161 | const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); |
| 144 | const encodeServerConfig = Schema.encodeSync(ServerConfig); | 162 | const encodeServerConfig = Schema.encodeSync(ServerConfig); |
| 163 | const encodeServerConfigStreamEvent = Schema.encodeSync(ServerConfigStreamEvent); | ||
| 164 | const encodeDefect = Schema.encodeSync(Schema.Defect()); | ||
| 145 | const ENCODED_SERVER_CONFIG = encodeServerConfig(SERVER_CONFIG); | 165 | const ENCODED_SERVER_CONFIG = encodeServerConfig(SERVER_CONFIG); |
| 166 | const THEME_SERVER_CONFIG: ServerConfigType = { | ||
| 167 | ...SERVER_CONFIG, | ||
| 168 | environment: { | ||
| 169 | ...SERVER_CONFIG.environment, | ||
| 170 | capabilities: { | ||
| 171 | ...SERVER_CONFIG.environment.capabilities, | ||
| 172 | environmentThemes: true, | ||
| 173 | }, | ||
| 174 | }, | ||
| 175 | }; | ||
| 176 | const ENCODED_THEME_SERVER_CONFIG = encodeServerConfig(THEME_SERVER_CONFIG); | ||
| 146 | const LEGACY_SERVER_CONFIG = { | 177 | const LEGACY_SERVER_CONFIG = { |
| 147 | ...ENCODED_SERVER_CONFIG, | 178 | ...ENCODED_SERVER_CONFIG, |
| 148 | environment: { | 179 | environment: { |
| ... | @@ -153,23 +184,26 @@ const LEGACY_SERVER_CONFIG = { | ... | @@ -153,23 +184,26 @@ const LEGACY_SERVER_CONFIG = { |
| 153 | }, | 184 | }, |
| 154 | }; | 185 | }; |
| 155 | 186 | ||
| 156 | const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () { | 187 | const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* ( |
| 188 | options: RpcSession.RpcSessionOptions = {}, | ||
| 189 | ) { | ||
| 157 | const sockets: TestWebSocket[] = []; | 190 | const sockets: TestWebSocket[] = []; |
| 158 | const constructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url) => { | 191 | const constructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url) => { |
| 159 | const socket = new TestWebSocket(url); | 192 | const socket = new TestWebSocket(url); |
| 160 | sockets.push(socket); | 193 | sockets.push(socket); |
| 161 | return socket as unknown as globalThis.WebSocket; | 194 | return socket as unknown as globalThis.WebSocket; |
| 162 | }); | 195 | }); |
| 163 | const layer = RpcSession.layer.pipe(Layer.provide(constructorLayer)); | 196 | const layer = RpcSession.layerWithOptions(options).pipe(Layer.provide(constructorLayer)); |
| 164 | const factory = yield* RpcSession.RpcSessionFactory.pipe(Effect.provide(layer)); | 197 | const factory = yield* RpcSession.RpcSessionFactory.pipe(Effect.provide(layer)); |
| 165 | return { factory, sockets }; | 198 | return { factory, sockets }; |
| 166 | }); | 199 | }); |
| 167 | 200 | ||
| 168 | const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* ( | 201 | const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* ( |
| 169 | sockets: ReadonlyArray<TestWebSocket>, | 202 | sockets: ReadonlyArray<TestWebSocket>, |
| 203 | index = 0, | ||
| 170 | ) { | 204 | ) { |
| 171 | for (let attempt = 0; attempt < 100; attempt += 1) { | 205 | for (let attempt = 0; attempt < 100; attempt += 1) { |
| 172 | const socket = sockets[0]; | 206 | const socket = sockets[index]; |
| 173 | if (socket) { | 207 | if (socket) { |
| 174 | return socket; | 208 | return socket; |
| 175 | } | 209 | } |
| ... | @@ -183,9 +217,9 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( | ... | @@ -183,9 +217,9 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( |
| 183 | index = 0, | 217 | index = 0, |
| 184 | ) { | 218 | ) { |
| 185 | for (let attempt = 0; attempt < 100; attempt += 1) { | 219 | for (let attempt = 0; attempt < 100; attempt += 1) { |
| 186 | const request = socket.sent[index]; | 220 | const request = socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)[index]; |
| 187 | if (request) { | 221 | if (request) { |
| 188 | return decodeRpcRequest(decodeJson(request)); | 222 | return request; |
| 189 | } | 223 | } |
| 190 | yield* Effect.yieldNow; | 224 | yield* Effect.yieldNow; |
| 191 | } | 225 | } |
| ... | @@ -195,21 +229,33 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( | ... | @@ -195,21 +229,33 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( |
| 195 | const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* ( | 229 | const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* ( |
| 196 | socket: TestWebSocket, | 230 | socket: TestWebSocket, |
| 197 | config: unknown = ENCODED_SERVER_CONFIG, | 231 | config: unknown = ENCODED_SERVER_CONFIG, |
| 232 | payload: unknown = {}, | ||
| 198 | ) { | 233 | ) { |
| 199 | const request = yield* awaitRequest(socket); | 234 | const request = yield* awaitRequest(socket); |
| 200 | expect(request).toMatchObject({ | 235 | expect(request).toMatchObject({ |
| 201 | _tag: "Request", | 236 | _tag: "Request", |
| 202 | tag: WS_METHODS.serverGetConfig, | 237 | tag: WS_METHODS.subscribeServerConfig, |
| 203 | payload: {}, | 238 | payload, |
| 204 | }); | 239 | }); |
| 205 | socket.serverMessage( | 240 | socket.serverMessage( |
| 206 | encodeJson({ | 241 | encodeJson({ |
| 207 | _tag: "Exit", | 242 | _tag: "Chunk", |
| 208 | requestId: request.id, | 243 | requestId: request.id, |
| 209 | exit: { | 244 | values: [{ version: 1, type: "snapshot", config }], |
| 210 | _tag: "Success", | 245 | }), |
| 211 | value: config, | 246 | ); |
| 212 | }, | 247 | }); |
| 248 | |||
| 249 | const publishConfigEvents = Effect.fn("TestRpcSessionFactory.publishConfigEvents")(function* ( | ||
| 250 | socket: TestWebSocket, | ||
| 251 | events: ReadonlyArray<ServerConfigStreamEventType>, | ||
| 252 | ) { | ||
| 253 | const request = yield* awaitRequest(socket); | ||
| 254 | socket.serverMessage( | ||
| 255 | encodeJson({ | ||
| 256 | _tag: "Chunk", | ||
| 257 | requestId: request.id, | ||
| 258 | values: events.map((event) => encodeServerConfigStreamEvent(event)), | ||
| 213 | }), | 259 | }), |
| 214 | ); | 260 | ); |
| 215 | }); | 261 | }); |
| ... | @@ -229,7 +275,9 @@ describe("RpcSessionFactory", () => { | ... | @@ -229,7 +275,9 @@ describe("RpcSessionFactory", () => { |
| 229 | 275 | ||
| 230 | const config = yield* session.initialConfig; | 276 | const config = yield* session.initialConfig; |
| 231 | expect(config).toEqual(SERVER_CONFIG); | 277 | expect(config).toEqual(SERVER_CONFIG); |
| 232 | expect(socket.sent).toHaveLength(1); | 278 | expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( |
| 279 | 1, | ||
| 280 | ); | ||
| 233 | 281 | ||
| 234 | const probeFiber = yield* Effect.forkChild(session.probe); | 282 | const probeFiber = yield* Effect.forkChild(session.probe); |
| 235 | const probeRequest = yield* awaitRequest(socket, 1); | 283 | const probeRequest = yield* awaitRequest(socket, 1); |
| ... | @@ -250,19 +298,25 @@ describe("RpcSessionFactory", () => { | ... | @@ -250,19 +298,25 @@ describe("RpcSessionFactory", () => { |
| 250 | ); | 298 | ); |
| 251 | yield* Fiber.join(probeFiber); | 299 | yield* Fiber.join(probeFiber); |
| 252 | 300 | ||
| 253 | expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([ | 301 | expect( |
| 254 | WS_METHODS.serverGetConfig, | 302 | socket.sent |
| 255 | WS_METHODS.serverProbe, | 303 | .map((message) => decodeJson(message)) |
| 256 | ]); | 304 | .filter(isRpcRequest) |
| 305 | .map((request) => request.tag), | ||
| 306 | ).toEqual([WS_METHODS.subscribeServerConfig, WS_METHODS.serverProbe]); | ||
| 257 | 307 | ||
| 258 | socket.close(1012, "service restart"); | 308 | socket.close(1012, "service restart"); |
| 259 | const error = yield* Effect.flip(session.closed); | 309 | const error = yield* Effect.flip(session.closed); |
| 310 | const configStreamError = yield* session | ||
| 311 | .subscribeServerConfig({}) | ||
| 312 | .pipe(Stream.runDrain, Effect.flip); | ||
| 260 | 313 | ||
| 261 | expect(error).toBeInstanceOf(ConnectionTransientError); | 314 | expect(error).toBeInstanceOf(ConnectionTransientError); |
| 262 | expect(error).toMatchObject({ | 315 | expect(error).toMatchObject({ |
| 263 | reason: "transport", | 316 | reason: "transport", |
| 264 | message: "Test environment disconnected.", | 317 | message: "Test environment disconnected.", |
| 265 | }); | 318 | }); |
| 319 | expect(configStreamError).toMatchObject({ _tag: "RpcClientError" }); | ||
| 266 | yield* Effect.yieldNow; | 320 | yield* Effect.yieldNow; |
| 267 | expect(sockets).toHaveLength(1); | 321 | expect(sockets).toHaveLength(1); |
| 268 | }), | 322 | }), |
| ... | @@ -287,6 +341,602 @@ describe("RpcSessionFactory", () => { | ... | @@ -287,6 +341,602 @@ describe("RpcSessionFactory", () => { |
| 287 | }), | 341 | }), |
| 288 | ); | 342 | ); |
| 289 | 343 | ||
| 344 | it.effect("replays current config and broadcasts updates to every subscriber", () => | ||
| 345 | Effect.scoped( | ||
| 346 | Effect.gen(function* () { | ||
| 347 | const { factory, sockets } = yield* makeFactory(); | ||
| 348 | const session = yield* factory.connect(PREPARED); | ||
| 349 | const readyFiber = yield* Effect.forkChild(session.ready); | ||
| 350 | const socket = yield* awaitSocket(sockets); | ||
| 351 | socket.open(); | ||
| 352 | yield* completeInitialConfig(socket); | ||
| 353 | yield* Fiber.join(readyFiber); | ||
| 354 | |||
| 355 | const collectTwo = session | ||
| 356 | .subscribeServerConfig({}) | ||
| 357 | .pipe(Stream.take(2), Stream.runCollect); | ||
| 358 | const firstSubscriber = yield* Effect.forkChild(collectTwo); | ||
| 359 | const secondSubscriber = yield* Effect.forkChild(collectTwo); | ||
| 360 | yield* Effect.yieldNow; | ||
| 361 | |||
| 362 | const shortcut = { | ||
| 363 | key: "k", | ||
| 364 | metaKey: false, | ||
| 365 | ctrlKey: false, | ||
| 366 | shiftKey: false, | ||
| 367 | altKey: false, | ||
| 368 | modKey: true, | ||
| 369 | }; | ||
| 370 | const request = yield* awaitRequest(socket); | ||
| 371 | socket.serverMessage( | ||
| 372 | encodeJson({ | ||
| 373 | _tag: "Chunk", | ||
| 374 | requestId: request.id, | ||
| 375 | values: [ | ||
| 376 | { | ||
| 377 | version: 1, | ||
| 378 | type: "keybindingsUpdated", | ||
| 379 | payload: { | ||
| 380 | keybindings: [{ command: "terminal.toggle", shortcut }], | ||
| 381 | issues: [], | ||
| 382 | }, | ||
| 383 | }, | ||
| 384 | ], | ||
| 385 | }), | ||
| 386 | ); | ||
| 387 | |||
| 388 | const firstEvents = Array.from(yield* Fiber.join(firstSubscriber)); | ||
| 389 | const secondEvents = Array.from(yield* Fiber.join(secondSubscriber)); | ||
| 390 | expect(firstEvents.map((event) => event.type)).toEqual(["snapshot", "keybindingsUpdated"]); | ||
| 391 | expect(secondEvents).toEqual(firstEvents); | ||
| 392 | |||
| 393 | const replay = yield* session.subscribeServerConfig({}).pipe(Stream.runHead); | ||
| 394 | expect(replay).toMatchObject({ | ||
| 395 | _tag: "Some", | ||
| 396 | value: { | ||
| 397 | type: "snapshot", | ||
| 398 | config: { keybindings: [{ command: "terminal.toggle", shortcut }] }, | ||
| 399 | }, | ||
| 400 | }); | ||
| 401 | }), | ||
| 402 | ), | ||
| 403 | ); | ||
| 404 | |||
| 405 | it.effect("shares only a config subscription with the same theme opt-in", () => | ||
| 406 | Effect.scoped( | ||
| 407 | Effect.gen(function* () { | ||
| 408 | const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); | ||
| 409 | const session = yield* factory.connect(PREPARED); | ||
| 410 | const readyFiber = yield* Effect.forkChild(session.ready); | ||
| 411 | const socket = yield* awaitSocket(sockets); | ||
| 412 | socket.open(); | ||
| 413 | yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { | ||
| 414 | environmentThemes: true, | ||
| 415 | }); | ||
| 416 | yield* Fiber.join(readyFiber); | ||
| 417 | |||
| 418 | const shared = yield* session | ||
| 419 | .subscribeServerConfig({ environmentThemes: true }) | ||
| 420 | .pipe(Stream.runHead); | ||
| 421 | expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } }); | ||
| 422 | expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( | ||
| 423 | 1, | ||
| 424 | ); | ||
| 425 | |||
| 426 | const fallbackFiber = yield* session | ||
| 427 | .subscribeServerConfig({}) | ||
| 428 | .pipe(Stream.runHead, Effect.forkChild); | ||
| 429 | const fallbackRequest = yield* awaitRequest(socket, 1); | ||
| 430 | expect(fallbackRequest).toMatchObject({ | ||
| 431 | tag: WS_METHODS.subscribeServerConfig, | ||
| 432 | payload: {}, | ||
| 433 | }); | ||
| 434 | socket.serverMessage( | ||
| 435 | encodeJson({ | ||
| 436 | _tag: "Chunk", | ||
| 437 | requestId: fallbackRequest.id, | ||
| 438 | values: [ | ||
| 439 | { | ||
| 440 | version: 1, | ||
| 441 | type: "snapshot", | ||
| 442 | config: ENCODED_THEME_SERVER_CONFIG, | ||
| 443 | }, | ||
| 444 | ], | ||
| 445 | }), | ||
| 446 | ); | ||
| 447 | expect(yield* Fiber.join(fallbackFiber)).toMatchObject({ | ||
| 448 | _tag: "Some", | ||
| 449 | value: { type: "snapshot" }, | ||
| 450 | }); | ||
| 451 | }), | ||
| 452 | ), | ||
| 453 | ); | ||
| 454 | |||
| 455 | it.effect("replays theme updates and deletion as authoritative events", () => | ||
| 456 | Effect.scoped( | ||
| 457 | Effect.gen(function* () { | ||
| 458 | const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); | ||
| 459 | const session = yield* factory.connect(PREPARED); | ||
| 460 | const readyFiber = yield* Effect.forkChild(session.ready); | ||
| 461 | const socket = yield* awaitSocket(sockets); | ||
| 462 | socket.open(); | ||
| 463 | yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { | ||
| 464 | environmentThemes: true, | ||
| 465 | }); | ||
| 466 | yield* Fiber.join(readyFiber); | ||
| 467 | |||
| 468 | const firstThemes = [ | ||
| 469 | { | ||
| 470 | id: "nightfall", | ||
| 471 | name: "Nightfall", | ||
| 472 | appearance: "dark" as const, | ||
| 473 | canvas: "#1a1b26", | ||
| 474 | accent: "#7aa2f7", | ||
| 475 | }, | ||
| 476 | ]; | ||
| 477 | const replacementThemes = [ | ||
| 478 | { | ||
| 479 | id: "midnight", | ||
| 480 | name: "Midnight", | ||
| 481 | appearance: "dark" as const, | ||
| 482 | canvas: "#000000", | ||
| 483 | accent: "#ffffff", | ||
| 484 | }, | ||
| 485 | ]; | ||
| 486 | const subscriberStarted = yield* Deferred.make<void>(); | ||
| 487 | const subscriber = yield* session.subscribeServerConfig({ environmentThemes: true }).pipe( | ||
| 488 | Stream.mapEffect((event) => | ||
| 489 | Deferred.succeed(subscriberStarted, undefined).pipe(Effect.as(event)), | ||
| 490 | ), | ||
| 491 | Stream.take(4), | ||
| 492 | Stream.runCollect, | ||
| 493 | Effect.forkChild, | ||
| 494 | ); | ||
| 495 | yield* Deferred.await(subscriberStarted); | ||
| 496 | yield* publishConfigEvents(socket, [ | ||
| 497 | { | ||
| 498 | version: 1, | ||
| 499 | type: "environmentThemesUpdated", | ||
| 500 | payload: { themes: firstThemes }, | ||
| 501 | }, | ||
| 502 | { | ||
| 503 | version: 1, | ||
| 504 | type: "environmentThemesUpdated", | ||
| 505 | payload: { themes: replacementThemes }, | ||
| 506 | }, | ||
| 507 | { | ||
| 508 | version: 1, | ||
| 509 | type: "environmentThemesUpdated", | ||
| 510 | payload: { themes: [] }, | ||
| 511 | }, | ||
| 512 | ]); | ||
| 513 | |||
| 514 | const liveEvents = Array.from(yield* Fiber.join(subscriber)); | ||
| 515 | expect(liveEvents.map((event) => event.type)).toEqual([ | ||
| 516 | "snapshot", | ||
| 517 | "environmentThemesUpdated", | ||
| 518 | "environmentThemesUpdated", | ||
| 519 | "environmentThemesUpdated", | ||
| 520 | ]); | ||
| 521 | expect(liveEvents[2]).toMatchObject({ payload: { themes: replacementThemes } }); | ||
| 522 | |||
| 523 | const replay = Array.from( | ||
| 524 | yield* session | ||
| 525 | .subscribeServerConfig({ environmentThemes: true }) | ||
| 526 | .pipe(Stream.take(2), Stream.runCollect), | ||
| 527 | ); | ||
| 528 | expect(replay.map((event) => event.type)).toEqual(["snapshot", "environmentThemesUpdated"]); | ||
| 529 | expect(replay[1]).toMatchObject({ payload: { themes: [] } }); | ||
| 530 | |||
| 531 | let projection = applyServerConfigProjection(Option.none(), { | ||
| 532 | version: 1, | ||
| 533 | type: "snapshot", | ||
| 534 | config: THEME_SERVER_CONFIG, | ||
| 535 | }); | ||
| 536 | projection = applyServerConfigProjection(projection, { | ||
| 537 | version: 1, | ||
| 538 | type: "environmentThemesUpdated", | ||
| 539 | payload: { themes: firstThemes }, | ||
| 540 | }); | ||
| 541 | for (const event of replay) { | ||
| 542 | projection = applyServerConfigProjection(projection, event); | ||
| 543 | } | ||
| 544 | expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined(); | ||
| 545 | }), | ||
| 546 | ), | ||
| 547 | ); | ||
| 548 | |||
| 549 | it.effect("recovers a slow subscriber after it misses theme deletion", () => | ||
| 550 | Effect.scoped( | ||
| 551 | Effect.gen(function* () { | ||
| 552 | const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); | ||
| 553 | const session = yield* factory.connect(PREPARED); | ||
| 554 | const readyFiber = yield* Effect.forkChild(session.ready); | ||
| 555 | const socket = yield* awaitSocket(sockets); | ||
| 556 | socket.open(); | ||
| 557 | yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { | ||
| 558 | environmentThemes: true, | ||
| 559 | }); | ||
| 560 | yield* Fiber.join(readyFiber); | ||
| 561 | |||
| 562 | const slowSubscriberStarted = yield* Deferred.make<void>(); | ||
| 563 | const releaseSlowSubscriber = yield* Deferred.make<void>(); | ||
| 564 | let firstEvent = true; | ||
| 565 | const slowSubscriber = yield* session | ||
| 566 | .subscribeServerConfig({ environmentThemes: true }) | ||
| 567 | .pipe( | ||
| 568 | Stream.mapEffect((event) => { | ||
| 569 | if (!firstEvent) return Effect.succeed(event); | ||
| 570 | firstEvent = false; | ||
| 571 | return Deferred.succeed(slowSubscriberStarted, undefined).pipe( | ||
| 572 | Effect.andThen(Deferred.await(releaseSlowSubscriber)), | ||
| 573 | Effect.as(event), | ||
| 574 | ); | ||
| 575 | }), | ||
| 576 | Stream.take(3), | ||
| 577 | Stream.runCollect, | ||
| 578 | Effect.forkChild, | ||
| 579 | ); | ||
| 580 | yield* Deferred.await(slowSubscriberStarted); | ||
| 581 | |||
| 582 | const firstThemes = [ | ||
| 583 | { | ||
| 584 | id: "nightfall", | ||
| 585 | name: "Nightfall", | ||
| 586 | appearance: "dark" as const, | ||
| 587 | canvas: "#1a1b26", | ||
| 588 | accent: "#7aa2f7", | ||
| 589 | }, | ||
| 590 | ]; | ||
| 591 | const themeEvents: ServerConfigStreamEventType[] = [ | ||
| 592 | { | ||
| 593 | version: 1, | ||
| 594 | type: "environmentThemesUpdated", | ||
| 595 | payload: { themes: firstThemes }, | ||
| 596 | }, | ||
| 597 | { | ||
| 598 | version: 1, | ||
| 599 | type: "environmentThemesUpdated", | ||
| 600 | payload: { | ||
| 601 | themes: [{ ...firstThemes[0]!, name: "Nightfall 2" }], | ||
| 602 | }, | ||
| 603 | }, | ||
| 604 | { | ||
| 605 | version: 1, | ||
| 606 | type: "environmentThemesUpdated", | ||
| 607 | payload: { themes: [] }, | ||
| 608 | }, | ||
| 609 | ]; | ||
| 610 | const settingsEvents = Array.from( | ||
| 611 | { length: 65 }, | ||
| 612 | (): ServerConfigStreamEventType => ({ | ||
| 613 | version: 1, | ||
| 614 | type: "settingsUpdated", | ||
| 615 | payload: { settings: DEFAULT_SERVER_SETTINGS }, | ||
| 616 | }), | ||
| 617 | ); | ||
| 618 | const allEvents = [...themeEvents, ...settingsEvents]; | ||
| 619 | const observedByFastSubscriber = yield* Queue.unbounded<ServerConfigStreamEventType>(); | ||
| 620 | yield* session.subscribeServerConfig({ environmentThemes: true }).pipe( | ||
| 621 | Stream.runForEach((event) => Queue.offer(observedByFastSubscriber, event)), | ||
| 622 | Effect.forkChild, | ||
| 623 | ); | ||
| 624 | expect((yield* Queue.take(observedByFastSubscriber)).type).toBe("snapshot"); | ||
| 625 | for (const event of allEvents) { | ||
| 626 | yield* publishConfigEvents(socket, [event]); | ||
| 627 | expect(yield* Queue.take(observedByFastSubscriber)).toEqual(event); | ||
| 628 | } | ||
| 629 | yield* Deferred.succeed(releaseSlowSubscriber, undefined); | ||
| 630 | |||
| 631 | const recovered = Array.from(yield* Fiber.join(slowSubscriber)); | ||
| 632 | expect(recovered.map((event) => event.type)).toEqual([ | ||
| 633 | "snapshot", | ||
| 634 | "snapshot", | ||
| 635 | "environmentThemesUpdated", | ||
| 636 | ]); | ||
| 637 | expect(recovered[2]).toMatchObject({ payload: { themes: [] } }); | ||
| 638 | |||
| 639 | let projection = applyServerConfigProjection(Option.none(), { | ||
| 640 | version: 1, | ||
| 641 | type: "snapshot", | ||
| 642 | config: THEME_SERVER_CONFIG, | ||
| 643 | }); | ||
| 644 | projection = applyServerConfigProjection(projection, themeEvents[0]!); | ||
| 645 | for (const event of recovered.slice(1)) { | ||
| 646 | projection = applyServerConfigProjection(projection, event); | ||
| 647 | } | ||
| 648 | expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined(); | ||
| 649 | }), | ||
| 650 | ), | ||
| 651 | ); | ||
| 652 | |||
| 653 | it.effect("closes the session when the config source dies", () => | ||
| 654 | Effect.scoped( | ||
| 655 | Effect.gen(function* () { | ||
| 656 | const { factory, sockets } = yield* makeFactory(); | ||
| 657 | const session = yield* factory.connect(PREPARED); | ||
| 658 | const readyFiber = yield* Effect.forkChild(session.ready); | ||
| 659 | const socket = yield* awaitSocket(sockets); | ||
| 660 | socket.open(); | ||
| 661 | yield* completeInitialConfig(socket); | ||
| 662 | yield* Fiber.join(readyFiber); | ||
| 663 | |||
| 664 | const closedFiber = yield* session.closed.pipe(Effect.exit, Effect.forkChild); | ||
| 665 | socket.serverMessage( | ||
| 666 | encodeJson({ | ||
| 667 | _tag: "Defect", | ||
| 668 | defect: encodeDefect(new Error("config stream died")), | ||
| 669 | }), | ||
| 670 | ); | ||
| 671 | |||
| 672 | const closed = yield* Fiber.join(closedFiber); | ||
| 673 | expect(Exit.isFailure(closed)).toBe(true); | ||
| 674 | if (Exit.isFailure(closed)) { | ||
| 675 | expect(Cause.hasDies(closed.cause)).toBe(true); | ||
| 676 | } | ||
| 677 | }), | ||
| 678 | ), | ||
| 679 | ); | ||
| 680 | |||
| 681 | it.effect.each([{ failure: "defect" as const }, { failure: "typed" as const }])( | ||
| 682 | "keeps durable config state alive after an owned $failure failure", | ||
| 683 | ({ failure }) => | ||
| 684 | Effect.scoped( | ||
| 685 | Effect.gen(function* () { | ||
| 686 | const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); | ||
| 687 | const firstSession = yield* factory.connect(PREPARED); | ||
| 688 | const firstReady = yield* Effect.forkChild(firstSession.ready); | ||
| 689 | const firstSocket = yield* awaitSocket(sockets); | ||
| 690 | firstSocket.open(); | ||
| 691 | yield* completeInitialConfig(firstSocket, ENCODED_THEME_SERVER_CONFIG, { | ||
| 692 | environmentThemes: true, | ||
| 693 | }); | ||
| 694 | yield* Fiber.join(firstReady); | ||
| 695 | |||
| 696 | const activeSession = yield* SubscriptionRef.make(Option.some(firstSession)); | ||
| 697 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | ||
| 698 | target: TARGET, | ||
| 699 | state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), | ||
| 700 | session: activeSession, | ||
| 701 | prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), | ||
| 702 | connect: Effect.void, | ||
| 703 | disconnect: Effect.void, | ||
| 704 | retryNow: Effect.void, | ||
| 705 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | ||
| 706 | const cache = Persistence.EnvironmentCacheStore.of({ | ||
| 707 | loadShell: () => Effect.succeed(Option.none()), | ||
| 708 | saveShell: () => Effect.void, | ||
| 709 | loadThread: () => Effect.succeed(Option.none()), | ||
| 710 | saveThread: () => Effect.void, | ||
| 711 | removeThread: () => Effect.void, | ||
| 712 | loadServerConfig: () => Effect.succeed(Option.none()), | ||
| 713 | saveServerConfig: () => Effect.void, | ||
| 714 | loadVcsRefs: () => Effect.succeed(Option.none()), | ||
| 715 | saveVcsRefs: () => Effect.void, | ||
| 716 | removeVcsRefs: () => Effect.void, | ||
| 717 | clearVcsRefs: () => Effect.void, | ||
| 718 | clear: () => Effect.void, | ||
| 719 | }); | ||
| 720 | const configState = yield* makeEnvironmentServerConfigState(true).pipe( | ||
| 721 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 722 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), | ||
| 723 | ); | ||
| 724 | const awaitConfig = (predicate: (config: ServerConfigType) => boolean) => | ||
| 725 | SubscriptionRef.changes(configState).pipe( | ||
| 726 | Stream.filter(Option.isSome), | ||
| 727 | Stream.map((projection) => projection.value.config), | ||
| 728 | Stream.filter(predicate), | ||
| 729 | Stream.runHead, | ||
| 730 | Effect.map(Option.getOrThrow), | ||
| 731 | ); | ||
| 732 | |||
| 733 | const firstThemes = [ | ||
| 734 | { | ||
| 735 | id: "first-theme", | ||
| 736 | name: "First theme", | ||
| 737 | appearance: "dark" as const, | ||
| 738 | canvas: "#111111", | ||
| 739 | accent: "#ffffff", | ||
| 740 | }, | ||
| 741 | ]; | ||
| 742 | const firstThemeState = yield* awaitConfig( | ||
| 743 | (config) => config.environmentThemes?.[0]?.id === "first-theme", | ||
| 744 | ).pipe(Effect.forkChild); | ||
| 745 | yield* publishConfigEvents(firstSocket, [ | ||
| 746 | { | ||
| 747 | version: 1, | ||
| 748 | type: "environmentThemesUpdated", | ||
| 749 | payload: { themes: firstThemes }, | ||
| 750 | }, | ||
| 751 | ]); | ||
| 752 | expect((yield* Fiber.join(firstThemeState)).environmentThemes).toEqual(firstThemes); | ||
| 753 | |||
| 754 | const firstClosed = yield* firstSession.closed.pipe(Effect.exit, Effect.forkChild); | ||
| 755 | const firstRequest = yield* awaitRequest(firstSocket); | ||
| 756 | firstSocket.serverMessage( | ||
| 757 | failure === "defect" | ||
| 758 | ? encodeJson({ | ||
| 759 | _tag: "Defect", | ||
| 760 | defect: encodeDefect(new Error("config stream died")), | ||
| 761 | }) | ||
| 762 | : encodeJson({ | ||
| 763 | _tag: "Exit", | ||
| 764 | requestId: firstRequest.id, | ||
| 765 | exit: { | ||
| 766 | _tag: "Failure", | ||
| 767 | cause: [ | ||
| 768 | { | ||
| 769 | _tag: "Fail", | ||
| 770 | error: { | ||
| 771 | _tag: "EnvironmentAuthorizationError", | ||
| 772 | message: "config subscription rejected", | ||
| 773 | requiredScope: "orchestration:read", | ||
| 774 | }, | ||
| 775 | }, | ||
| 776 | ], | ||
| 777 | }, | ||
| 778 | }), | ||
| 779 | ); | ||
| 780 | const firstClosedExit = yield* Fiber.join(firstClosed); | ||
| 781 | expect(Exit.isFailure(firstClosedExit)).toBe(true); | ||
| 782 | if (failure === "typed" && Exit.isFailure(firstClosedExit)) { | ||
| 783 | expect(Cause.squash(firstClosedExit.cause)).toBeInstanceOf(ConnectionBlockedError); | ||
| 784 | expect(Cause.squash(firstClosedExit.cause)).toMatchObject({ reason: "permission" }); | ||
| 785 | } | ||
| 786 | yield* SubscriptionRef.set(activeSession, Option.none()); | ||
| 787 | |||
| 788 | const recoveredConfig = { | ||
| 789 | ...THEME_SERVER_CONFIG, | ||
| 790 | environment: { | ||
| 791 | ...THEME_SERVER_CONFIG.environment, | ||
| 792 | label: "Recovered environment", | ||
| 793 | }, | ||
| 794 | } satisfies ServerConfigType; | ||
| 795 | const secondSession = yield* factory.connect(PREPARED); | ||
| 796 | const secondReady = yield* Effect.forkChild(secondSession.ready); | ||
| 797 | const secondSocket = yield* awaitSocket(sockets, 1); | ||
| 798 | secondSocket.open(); | ||
| 799 | yield* completeInitialConfig(secondSocket, encodeServerConfig(recoveredConfig), { | ||
| 800 | environmentThemes: true, | ||
| 801 | }); | ||
| 802 | yield* Fiber.join(secondReady); | ||
| 803 | |||
| 804 | const recoveredState = yield* awaitConfig( | ||
| 805 | (config) => config.environment.label === "Recovered environment", | ||
| 806 | ).pipe(Effect.forkChild); | ||
| 807 | yield* SubscriptionRef.set(activeSession, Option.some(secondSession)); | ||
| 808 | expect((yield* Fiber.join(recoveredState)).environmentThemes).toEqual(firstThemes); | ||
| 809 | |||
| 810 | const recoveredThemes = [ | ||
| 811 | { | ||
| 812 | id: "recovered-theme", | ||
| 813 | name: "Recovered theme", | ||
| 814 | appearance: "dark" as const, | ||
| 815 | canvas: "#000000", | ||
| 816 | accent: "#eeeeee", | ||
| 817 | }, | ||
| 818 | ]; | ||
| 819 | const liveRecoveredState = yield* awaitConfig( | ||
| 820 | (config) => config.environmentThemes?.[0]?.id === "recovered-theme", | ||
| 821 | ).pipe(Effect.forkChild); | ||
| 822 | yield* publishConfigEvents(secondSocket, [ | ||
| 823 | { | ||
| 824 | version: 1, | ||
| 825 | type: "environmentThemesUpdated", | ||
| 826 | payload: { themes: recoveredThemes }, | ||
| 827 | }, | ||
| 828 | ]); | ||
| 829 | expect((yield* Fiber.join(liveRecoveredState)).environmentThemes).toEqual( | ||
| 830 | recoveredThemes, | ||
| 831 | ); | ||
| 832 | }), | ||
| 833 | ), | ||
| 834 | ); | ||
| 835 | |||
| 836 | it.effect.each<{ | ||
| 837 | readonly event: ServerConfigStreamEventType; | ||
| 838 | readonly expectedConfig: Partial<ServerConfigType>; | ||
| 839 | }>([ | ||
| 840 | { | ||
| 841 | event: { | ||
| 842 | version: 1, | ||
| 843 | type: "providerStatuses", | ||
| 844 | payload: { | ||
| 845 | providers: [ | ||
| 846 | { | ||
| 847 | instanceId: ProviderInstanceId.make("codex"), | ||
| 848 | driver: ProviderDriverKind.make("codex"), | ||
| 849 | enabled: true, | ||
| 850 | installed: true, | ||
| 851 | version: "1.0.0", | ||
| 852 | status: "ready", | ||
| 853 | auth: { status: "authenticated" }, | ||
| 854 | checkedAt: "2026-08-27T00:00:00.000Z", | ||
| 855 | models: [], | ||
| 856 | slashCommands: [], | ||
| 857 | skills: [], | ||
| 858 | }, | ||
| 859 | ], | ||
| 860 | }, | ||
| 861 | }, | ||
| 862 | expectedConfig: { | ||
| 863 | providers: [ | ||
| 864 | { | ||
| 865 | instanceId: ProviderInstanceId.make("codex"), | ||
| 866 | driver: ProviderDriverKind.make("codex"), | ||
| 867 | enabled: true, | ||
| 868 | installed: true, | ||
| 869 | version: "1.0.0", | ||
| 870 | status: "ready", | ||
| 871 | auth: { status: "authenticated" }, | ||
| 872 | checkedAt: "2026-08-27T00:00:00.000Z", | ||
| 873 | models: [], | ||
| 874 | slashCommands: [], | ||
| 875 | skills: [], | ||
| 876 | }, | ||
| 877 | ], | ||
| 878 | }, | ||
| 879 | }, | ||
| 880 | { | ||
| 881 | event: { | ||
| 882 | version: 1, | ||
| 883 | type: "settingsUpdated", | ||
| 884 | payload: { | ||
| 885 | settings: { | ||
| 886 | ...DEFAULT_SERVER_SETTINGS, | ||
| 887 | newWorktreesStartFromOrigin: !DEFAULT_SERVER_SETTINGS.newWorktreesStartFromOrigin, | ||
| 888 | }, | ||
| 889 | }, | ||
| 890 | }, | ||
| 891 | expectedConfig: { | ||
| 892 | settings: { | ||
| 893 | ...DEFAULT_SERVER_SETTINGS, | ||
| 894 | newWorktreesStartFromOrigin: !DEFAULT_SERVER_SETTINGS.newWorktreesStartFromOrigin, | ||
| 895 | }, | ||
| 896 | }, | ||
| 897 | }, | ||
| 898 | ])( | ||
| 899 | "preserves $event.type events and includes them in replay snapshots", | ||
| 900 | ({ event, expectedConfig }) => | ||
| 901 | Effect.scoped( | ||
| 902 | Effect.gen(function* () { | ||
| 903 | const { factory, sockets } = yield* makeFactory(); | ||
| 904 | const session = yield* factory.connect(PREPARED); | ||
| 905 | const readyFiber = yield* Effect.forkChild(session.ready); | ||
| 906 | const socket = yield* awaitSocket(sockets); | ||
| 907 | socket.open(); | ||
| 908 | yield* completeInitialConfig(socket); | ||
| 909 | yield* Fiber.join(readyFiber); | ||
| 910 | |||
| 911 | const subscriber = yield* session | ||
| 912 | .subscribeServerConfig({}) | ||
| 913 | .pipe(Stream.take(2), Stream.runCollect, Effect.forkChild); | ||
| 914 | yield* Effect.yieldNow; | ||
| 915 | |||
| 916 | const request = yield* awaitRequest(socket); | ||
| 917 | socket.serverMessage( | ||
| 918 | encodeJson({ | ||
| 919 | _tag: "Chunk", | ||
| 920 | requestId: request.id, | ||
| 921 | values: [encodeServerConfigStreamEvent(event)], | ||
| 922 | }), | ||
| 923 | ); | ||
| 924 | |||
| 925 | const events = Array.from(yield* Fiber.join(subscriber)); | ||
| 926 | expect(events[1]).toEqual(event); | ||
| 927 | |||
| 928 | const replay = yield* session.subscribeServerConfig({}).pipe(Stream.runHead); | ||
| 929 | expect(replay).toMatchObject({ | ||
| 930 | _tag: "Some", | ||
| 931 | value: { | ||
| 932 | type: "snapshot", | ||
| 933 | config: expectedConfig, | ||
| 934 | }, | ||
| 935 | }); | ||
| 936 | }), | ||
| 937 | ), | ||
| 938 | ); | ||
| 939 | |||
| 290 | it.effect("tolerates two missed pong windows before closing the session", () => | 940 | it.effect("tolerates two missed pong windows before closing the session", () => |
| 291 | Effect.gen(function* () { | 941 | Effect.gen(function* () { |
| 292 | const { factory, sockets } = yield* makeFactory(); | 942 | const { factory, sockets } = yield* makeFactory(); |
| ... | @@ -301,7 +951,7 @@ describe("RpcSessionFactory", () => { | ... | @@ -301,7 +951,7 @@ describe("RpcSessionFactory", () => { |
| 301 | 951 | ||
| 302 | yield* TestClock.adjust("15 seconds"); | 952 | yield* TestClock.adjust("15 seconds"); |
| 303 | expect(closedFiber.pollUnsafe()).toBeUndefined(); | 953 | expect(closedFiber.pollUnsafe()).toBeUndefined(); |
| 304 | expect(socket.sent.slice(1).map((request) => decodeJson(request))).toEqual([ | 954 | expect(socket.sent.map((message) => decodeJson(message)).filter(isPing)).toEqual([ |
| 305 | { _tag: "Ping" }, | 955 | { _tag: "Ping" }, |
| 306 | { _tag: "Ping" }, | 956 | { _tag: "Ping" }, |
| 307 | { _tag: "Ping" }, | 957 | { _tag: "Ping" }, |
| ... | @@ -379,10 +1029,12 @@ describe("RpcSessionFactory", () => { | ... | @@ -379,10 +1029,12 @@ describe("RpcSessionFactory", () => { |
| 379 | ); | 1029 | ); |
| 380 | yield* Fiber.join(probeFiber); | 1030 | yield* Fiber.join(probeFiber); |
| 381 | 1031 | ||
| 382 | expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([ | 1032 | expect( |
| 383 | WS_METHODS.serverGetConfig, | 1033 | socket.sent |
| 384 | WS_METHODS.serverGetConfig, | 1034 | .map((message) => decodeJson(message)) |
| 385 | ]); | 1035 | .filter(isRpcRequest) |
| 1036 | .map((request) => request.tag), | ||
| 1037 | ).toEqual([WS_METHODS.subscribeServerConfig, WS_METHODS.serverGetConfig]); | ||
| 386 | }), | 1038 | }), |
| 387 | ), | 1039 | ), |
| 388 | ); | 1040 | ); |
packages/client-runtime/src/rpc/session.ts+206-13| ... | @@ -1,11 +1,25 @@ | ... | @@ -1,11 +1,25 @@ |
| 1 | import { type ServerConfig, WS_METHODS } from "@t3tools/contracts"; | 1 | import { |
| 2 | type ServerConfig, | ||
| 3 | type ServerConfigStreamEvent, | ||
| 4 | WsSubscribeServerConfigRpc, | ||
| 5 | WS_METHODS, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as Cause from "effect/Cause"; | ||
| 2 | import * as Context from "effect/Context"; | 8 | import * as Context from "effect/Context"; |
| 3 | import * as Deferred from "effect/Deferred"; | 9 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Equal from "effect/Equal"; | ||
| 12 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as Layer from "effect/Layer"; | 13 | import * as Layer from "effect/Layer"; |
| 14 | import * as Option from "effect/Option"; | ||
| 15 | import * as PubSub from "effect/PubSub"; | ||
| 16 | import * as Ref from "effect/Ref"; | ||
| 6 | import * as Schedule from "effect/Schedule"; | 17 | import * as Schedule from "effect/Schedule"; |
| 7 | import type * as Scope from "effect/Scope"; | 18 | import type * as Scope from "effect/Scope"; |
| 19 | import * as Stream from "effect/Stream"; | ||
| 20 | import type * as Rpc from "effect/unstable/rpc/Rpc"; | ||
| 8 | import * as RpcClient from "effect/unstable/rpc/RpcClient"; | 21 | import * as RpcClient from "effect/unstable/rpc/RpcClient"; |
| 22 | import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; | ||
| 9 | import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; | 23 | import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; |
| 10 | import * as Socket from "effect/unstable/socket/Socket"; | 24 | import * as Socket from "effect/unstable/socket/Socket"; |
| 11 | 25 | ||
| ... | @@ -19,15 +33,27 @@ import { | ... | @@ -19,15 +33,27 @@ import { |
| 19 | ConnectionBlockedError, | 33 | ConnectionBlockedError, |
| 20 | ConnectionTransientError as ConnectionTransientErrorClass, | 34 | ConnectionTransientError as ConnectionTransientErrorClass, |
| 21 | } from "../connection/model.ts"; | 35 | } from "../connection/model.ts"; |
| 36 | import { | ||
| 37 | applyServerConfigProjection, | ||
| 38 | type ServerConfigProjection, | ||
| 39 | withoutEnvironmentThemes, | ||
| 40 | } from "../state/serverConfigProjection.ts"; | ||
| 22 | 41 | ||
| 23 | const SOCKET_OPEN_TIMEOUT = "15 seconds"; | 42 | const SOCKET_OPEN_TIMEOUT = "15 seconds"; |
| 24 | 43 | ||
| 25 | export interface RpcSession { | 44 | export interface RpcSession { |
| 26 | readonly client: WsRpcProtocolClient; | 45 | readonly client: WsRpcProtocolClient; |
| 27 | readonly initialConfig: Effect.Effect<ServerConfig, ConnectionAttemptError>; | 46 | readonly initialConfig: Effect.Effect<ServerConfig, ConnectionAttemptError>; |
| 47 | readonly subscribeServerConfig: ( | ||
| 48 | input: ServerConfigSubscriptionInput, | ||
| 49 | ) => ServerConfigSubscription; | ||
| 28 | readonly ready: Effect.Effect<void, ConnectionAttemptError>; | 50 | readonly ready: Effect.Effect<void, ConnectionAttemptError>; |
| 29 | readonly probe: Effect.Effect<void, ConnectionAttemptError>; | 51 | readonly probe: Effect.Effect<void, ConnectionAttemptError>; |
| 30 | readonly closed: Effect.Effect<never, ConnectionTransientError>; | 52 | readonly closed: Effect.Effect<never, ConnectionAttemptError>; |
| 53 | } | ||
| 54 | |||
| 55 | export interface RpcSessionOptions { | ||
| 56 | readonly environmentThemes?: boolean; | ||
| 31 | } | 57 | } |
| 32 | 58 | ||
| 33 | export class RpcSessionFactory extends Context.Service< | 59 | export class RpcSessionFactory extends Context.Service< |
| ... | @@ -43,8 +69,47 @@ type InitialConfigError = Effect.Error< | ... | @@ -43,8 +69,47 @@ type InitialConfigError = Effect.Error< |
| 43 | ReturnType<WsRpcProtocolClient[typeof WS_METHODS.serverGetConfig]> | 69 | ReturnType<WsRpcProtocolClient[typeof WS_METHODS.serverGetConfig]> |
| 44 | >; | 70 | >; |
| 45 | type ProbeError = Effect.Error<ReturnType<WsRpcProtocolClient[typeof WS_METHODS.serverProbe]>>; | 71 | type ProbeError = Effect.Error<ReturnType<WsRpcProtocolClient[typeof WS_METHODS.serverProbe]>>; |
| 72 | type ServerConfigSubscriptionError = | ||
| 73 | | Rpc.ErrorExit<typeof WsSubscribeServerConfigRpc> | ||
| 74 | | RpcClientError.RpcClientError; | ||
| 75 | type ServerConfigSubscription = Stream.Stream< | ||
| 76 | ServerConfigStreamEvent, | ||
| 77 | ServerConfigSubscriptionError | ||
| 78 | >; | ||
| 79 | type ServerConfigSubscriptionInput = Parameters< | ||
| 80 | WsRpcProtocolClient[typeof WS_METHODS.subscribeServerConfig] | ||
| 81 | >[0]; | ||
| 82 | type EnvironmentThemesUpdatedEvent = Extract< | ||
| 83 | ServerConfigStreamEvent, | ||
| 84 | { readonly type: "environmentThemesUpdated" } | ||
| 85 | >; | ||
| 86 | |||
| 87 | interface ServerConfigReplayState { | ||
| 88 | readonly projection: ServerConfigProjection; | ||
| 89 | readonly revision: number; | ||
| 90 | readonly themesEvent: EnvironmentThemesUpdatedEvent | undefined; | ||
| 91 | } | ||
| 92 | |||
| 93 | interface BufferedServerConfigEvent { | ||
| 94 | readonly event: ServerConfigStreamEvent; | ||
| 95 | readonly replay: ServerConfigReplayState; | ||
| 96 | readonly revision: number; | ||
| 97 | } | ||
| 98 | |||
| 99 | function serverConfigReplayEvents( | ||
| 100 | state: ServerConfigReplayState, | ||
| 101 | ): ReadonlyArray<ServerConfigStreamEvent> { | ||
| 102 | const snapshot = { | ||
| 103 | version: 1 as const, | ||
| 104 | type: "snapshot" as const, | ||
| 105 | config: withoutEnvironmentThemes(state.projection.config), | ||
| 106 | }; | ||
| 107 | return state.themesEvent === undefined ? [snapshot] : [snapshot, state.themesEvent]; | ||
| 108 | } | ||
| 46 | 109 | ||
| 47 | function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionAttemptError { | 110 | function mapSessionRpcError( |
| 111 | error: InitialConfigError | ProbeError | ServerConfigSubscriptionError, | ||
| 112 | ): ConnectionAttemptError { | ||
| 48 | switch (error._tag) { | 113 | switch (error._tag) { |
| 49 | case "EnvironmentAuthorizationError": | 114 | case "EnvironmentAuthorizationError": |
| 50 | return new ConnectionBlockedError({ | 115 | return new ConnectionBlockedError({ |
| ... | @@ -65,8 +130,12 @@ function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionA | ... | @@ -65,8 +130,12 @@ function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionA |
| 65 | } | 130 | } |
| 66 | } | 131 | } |
| 67 | 132 | ||
| 68 | export const make = Effect.gen(function* () { | 133 | export const make = Effect.fn("RpcSessionFactory.make")(function* ( |
| 134 | options: RpcSessionOptions = {}, | ||
| 135 | ) { | ||
| 69 | const webSocketConstructor = yield* Socket.WebSocketConstructor; | 136 | const webSocketConstructor = yield* Socket.WebSocketConstructor; |
| 137 | const serverConfigInput: ServerConfigSubscriptionInput = | ||
| 138 | options.environmentThemes === true ? { environmentThemes: true } : {}; | ||
| 70 | 139 | ||
| 71 | const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { | 140 | const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { |
| 72 | yield* Effect.annotateCurrentSpan({ | 141 | yield* Effect.annotateCurrentSpan({ |
| ... | @@ -113,18 +182,135 @@ export const make = Effect.gen(function* () { | ... | @@ -113,18 +182,135 @@ export const make = Effect.gen(function* () { |
| 113 | const protocolContext = yield* Layer.build(protocolLayer).pipe( | 182 | const protocolContext = yield* Layer.build(protocolLayer).pipe( |
| 114 | Effect.withSpan("environment.websocket.connect"), | 183 | Effect.withSpan("environment.websocket.connect"), |
| 115 | ); | 184 | ); |
| 116 | const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); | 185 | const protocolClient = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); |
| 117 | const initialConfig = yield* Effect.cached( | 186 | const initialConfigDeferred = yield* Deferred.make<ServerConfig>(); |
| 118 | client[WS_METHODS.serverGetConfig]({}).pipe( | 187 | const serverConfigExit = yield* Deferred.make<void, ServerConfigSubscriptionError>(); |
| 188 | const configSubscriptionClosed = yield* Deferred.make<never, ConnectionAttemptError>(); | ||
| 189 | const serverConfigState = yield* Ref.make(Option.none<ServerConfigReplayState>()); | ||
| 190 | const serverConfigUpdates = yield* PubSub.sliding<BufferedServerConfigEvent>(64); | ||
| 191 | const configSubscriptionEndedError = new ConnectionTransientErrorClass({ | ||
| 192 | reason: "remote-unavailable", | ||
| 193 | detail: `${connection.label} config subscription ended.`, | ||
| 194 | }); | ||
| 195 | const serverConfigSource = protocolClient[WS_METHODS.subscribeServerConfig]( | ||
| 196 | serverConfigInput, | ||
| 197 | ).pipe( | ||
| 198 | Stream.runForEach((event) => | ||
| 199 | Effect.gen(function* () { | ||
| 200 | const buffered = yield* Ref.modify(serverConfigState, (current) => { | ||
| 201 | const projection = applyServerConfigProjection( | ||
| 202 | Option.map(current, (state) => state.projection), | ||
| 203 | event, | ||
| 204 | ); | ||
| 205 | if (Option.isNone(projection)) { | ||
| 206 | return [Option.none<BufferedServerConfigEvent>(), current] as const; | ||
| 207 | } | ||
| 208 | const next = { | ||
| 209 | projection: projection.value, | ||
| 210 | revision: Option.match(current, { | ||
| 211 | onNone: () => 1, | ||
| 212 | onSome: (state) => state.revision + 1, | ||
| 213 | }), | ||
| 214 | themesEvent: | ||
| 215 | event.type === "environmentThemesUpdated" | ||
| 216 | ? event | ||
| 217 | : event.type === "snapshot" && | ||
| 218 | event.config.environment.capabilities.environmentThemes !== true | ||
| 219 | ? undefined | ||
| 220 | : Option.getOrUndefined(current)?.themesEvent, | ||
| 221 | } satisfies ServerConfigReplayState; | ||
| 222 | return [ | ||
| 223 | Option.some({ event, replay: next, revision: next.revision }), | ||
| 224 | Option.some(next), | ||
| 225 | ] as const; | ||
| 226 | }); | ||
| 227 | if (Option.isSome(buffered)) { | ||
| 228 | yield* PubSub.publish(serverConfigUpdates, buffered.value); | ||
| 229 | } | ||
| 230 | if (event.type === "snapshot") { | ||
| 231 | yield* Deferred.succeed(initialConfigDeferred, event.config); | ||
| 232 | } | ||
| 233 | }), | ||
| 234 | ), | ||
| 235 | Effect.onExit((exit) => { | ||
| 236 | if (Exit.isSuccess(exit)) { | ||
| 237 | return Effect.all([ | ||
| 238 | Deferred.succeed(serverConfigExit, undefined), | ||
| 239 | Deferred.fail(configSubscriptionClosed, configSubscriptionEndedError), | ||
| 240 | ]).pipe(Effect.asVoid); | ||
| 241 | } | ||
| 242 | if (Cause.hasInterruptsOnly(exit.cause)) { | ||
| 243 | return Effect.void; | ||
| 244 | } | ||
| 245 | return Effect.all([ | ||
| 246 | Deferred.failCause(serverConfigExit, exit.cause), | ||
| 247 | Deferred.failCause(configSubscriptionClosed, Cause.map(exit.cause, mapSessionRpcError)), | ||
| 248 | ]).pipe(Effect.asVoid); | ||
| 249 | }), | ||
| 250 | ); | ||
| 251 | yield* serverConfigSource.pipe(Effect.forkScoped); | ||
| 252 | const initialConfig = Effect.raceFirst( | ||
| 253 | Deferred.await(initialConfigDeferred), | ||
| 254 | Deferred.await(serverConfigExit).pipe( | ||
| 119 | Effect.mapError(mapSessionRpcError), | 255 | Effect.mapError(mapSessionRpcError), |
| 120 | Effect.withSpan("environment.initialSync"), | 256 | Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)), |
| 121 | ), | 257 | ), |
| 258 | ).pipe(Effect.withSpan("environment.initialSync")); | ||
| 259 | const serverConfigEvents = Stream.unwrap( | ||
| 260 | Effect.gen(function* () { | ||
| 261 | const subscription = yield* PubSub.subscribe(serverConfigUpdates); | ||
| 262 | yield* Effect.raceFirst( | ||
| 263 | Deferred.await(initialConfigDeferred).pipe(Effect.asVoid), | ||
| 264 | Deferred.await(serverConfigExit), | ||
| 265 | ); | ||
| 266 | const snapshot = yield* Ref.get(serverConfigState); | ||
| 267 | if (Option.isNone(snapshot)) { | ||
| 268 | return Stream.empty; | ||
| 269 | } | ||
| 270 | const updates = Stream.fromSubscription(subscription).pipe( | ||
| 271 | Stream.filter((buffered) => buffered.revision > snapshot.value.revision), | ||
| 272 | Stream.mapAccum( | ||
| 273 | () => snapshot.value.revision, | ||
| 274 | (revision, buffered) => [ | ||
| 275 | buffered.revision, | ||
| 276 | buffered.revision === revision + 1 | ||
| 277 | ? [buffered.event] | ||
| 278 | : serverConfigReplayEvents(buffered.replay), | ||
| 279 | ], | ||
| 280 | ), | ||
| 281 | ); | ||
| 282 | const terminal = Stream.fromEffect(Deferred.await(serverConfigExit)).pipe(Stream.drain); | ||
| 283 | return Stream.concat( | ||
| 284 | Stream.fromIterable(serverConfigReplayEvents(snapshot.value)), | ||
| 285 | Stream.merge(updates, terminal, { haltStrategy: "either" }), | ||
| 286 | ); | ||
| 287 | }), | ||
| 288 | ).pipe( | ||
| 289 | Stream.catchCause((cause) => { | ||
| 290 | if (Cause.hasInterruptsOnly(cause)) { | ||
| 291 | return Stream.failCause(cause); | ||
| 292 | } | ||
| 293 | // The supervisor keeps the original cause. Shared durable consumers | ||
| 294 | // need a transport-shaped failure so they wait for its replacement. | ||
| 295 | return Stream.fail( | ||
| 296 | new RpcClientError.RpcClientError({ | ||
| 297 | reason: new RpcClientError.RpcClientDefect({ | ||
| 298 | message: `${connection.label} config subscription failed.`, | ||
| 299 | cause, | ||
| 300 | }), | ||
| 301 | }), | ||
| 302 | ); | ||
| 303 | }), | ||
| 122 | ); | 304 | ); |
| 305 | const subscribeServerConfig = (input: ServerConfigSubscriptionInput) => | ||
| 306 | Equal.equals(input, serverConfigInput) | ||
| 307 | ? serverConfigEvents | ||
| 308 | : protocolClient[WS_METHODS.subscribeServerConfig](input); | ||
| 123 | const probe = initialConfig.pipe( | 309 | const probe = initialConfig.pipe( |
| 124 | Effect.flatMap((config) => | 310 | Effect.flatMap((config) => |
| 125 | (config.environment.capabilities.connectionProbe === true | 311 | (config.environment.capabilities.connectionProbe === true |
| 126 | ? client[WS_METHODS.serverProbe]({}) | 312 | ? protocolClient[WS_METHODS.serverProbe]({}) |
| 127 | : client[WS_METHODS.serverGetConfig]({}) | 313 | : protocolClient[WS_METHODS.serverGetConfig]({}) |
| 128 | ).pipe(Effect.mapError(mapSessionRpcError)), | 314 | ).pipe(Effect.mapError(mapSessionRpcError)), |
| 129 | ), | 315 | ), |
| 130 | Effect.asVoid, | 316 | Effect.asVoid, |
| ... | @@ -132,19 +318,26 @@ export const make = Effect.gen(function* () { | ... | @@ -132,19 +318,26 @@ export const make = Effect.gen(function* () { |
| 132 | ); | 318 | ); |
| 133 | 319 | ||
| 134 | return { | 320 | return { |
| 135 | client, | 321 | client: protocolClient, |
| 136 | initialConfig, | 322 | initialConfig, |
| 323 | subscribeServerConfig, | ||
| 137 | ready: Deferred.await(connected).pipe( | 324 | ready: Deferred.await(connected).pipe( |
| 138 | Effect.andThen(initialConfig), | 325 | Effect.andThen(initialConfig), |
| 139 | Effect.asVoid, | 326 | Effect.asVoid, |
| 140 | Effect.raceFirst(Deferred.await(disconnected)), | 327 | Effect.raceFirst(Deferred.await(disconnected)), |
| 141 | ), | 328 | ), |
| 142 | probe, | 329 | probe, |
| 143 | closed: Deferred.await(disconnected), | 330 | closed: Effect.raceFirst( |
| 331 | Deferred.await(disconnected), | ||
| 332 | Deferred.await(configSubscriptionClosed), | ||
| 333 | ), | ||
| 144 | } satisfies RpcSession; | 334 | } satisfies RpcSession; |
| 145 | }); | 335 | }); |
| 146 | 336 | ||
| 147 | return RpcSessionFactory.of({ connect }); | 337 | return RpcSessionFactory.of({ connect }); |
| 148 | }); | 338 | }); |
| 149 | 339 | ||
| 150 | export const layer = Layer.effect(RpcSessionFactory, make); | 340 | export const layerWithOptions = (options: RpcSessionOptions) => |
| 341 | Layer.effect(RpcSessionFactory, make(options)); | ||
| 342 | |||
| 343 | export const layer = layerWithOptions({}); |
packages/client-runtime/src/state/pullRequests.test.ts+1| ... | @@ -32,6 +32,7 @@ function session(client: WsRpcProtocolClient): RpcSession { | ... | @@ -32,6 +32,7 @@ function session(client: WsRpcProtocolClient): RpcSession { |
| 32 | return { | 32 | return { |
| 33 | client, | 33 | client, |
| 34 | initialConfig: Effect.never, | 34 | initialConfig: Effect.never, |
| 35 | subscribeServerConfig: (input) => client.subscribeServerConfig(input), | ||
| 35 | ready: Effect.void, | 36 | ready: Effect.void, |
| 36 | probe: Effect.void, | 37 | probe: Effect.void, |
| 37 | closed: Effect.never, | 38 | closed: Effect.never, |
packages/client-runtime/src/state/server.test.ts+2-1| ... | @@ -30,7 +30,6 @@ import * as Persistence from "../platform/persistence.ts"; | ... | @@ -30,7 +30,6 @@ import * as Persistence from "../platform/persistence.ts"; |
| 30 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | 30 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 31 | import type { RpcSession } from "../rpc/session.ts"; | 31 | import type { RpcSession } from "../rpc/session.ts"; |
| 32 | import { | 32 | import { |
| 33 | applyServerConfigProjection, | ||
| 34 | makeEnvironmentServerConfigState, | 33 | makeEnvironmentServerConfigState, |
| 35 | isLegacyUpdateHandoffLoss, | 34 | isLegacyUpdateHandoffLoss, |
| 36 | matchesServerUpdateReadyEvent, | 35 | matchesServerUpdateReadyEvent, |
| ... | @@ -42,6 +41,7 @@ import { | ... | @@ -42,6 +41,7 @@ import { |
| 42 | serverUpdateStateForServerVersion, | 41 | serverUpdateStateForServerVersion, |
| 43 | validateServerUpdateReadyEvent, | 42 | validateServerUpdateReadyEvent, |
| 44 | } from "./server.ts"; | 43 | } from "./server.ts"; |
| 44 | import { applyServerConfigProjection } from "./serverConfigProjection.ts"; | ||
| 45 | 45 | ||
| 46 | const CONFIG = { | 46 | const CONFIG = { |
| 47 | availableEditors: [], | 47 | availableEditors: [], |
| ... | @@ -73,6 +73,7 @@ function session(client: WsRpcProtocolClient): RpcSession { | ... | @@ -73,6 +73,7 @@ function session(client: WsRpcProtocolClient): RpcSession { |
| 73 | return { | 73 | return { |
| 74 | client, | 74 | client, |
| 75 | initialConfig: Effect.succeed(CONFIG), | 75 | initialConfig: Effect.succeed(CONFIG), |
| 76 | subscribeServerConfig: (input) => client.subscribeServerConfig(input), | ||
| 76 | ready: Effect.void, | 77 | ready: Effect.void, |
| 77 | probe: Effect.void, | 78 | probe: Effect.void, |
| 78 | closed: Effect.never, | 79 | closed: Effect.never, |
packages/client-runtime/src/state/server.ts+8-85| ... | @@ -41,6 +41,14 @@ import { | ... | @@ -41,6 +41,14 @@ import { |
| 41 | type EnvironmentRpcInput, | 41 | type EnvironmentRpcInput, |
| 42 | } from "../rpc/client.ts"; | 42 | } from "../rpc/client.ts"; |
| 43 | import { followStreamInEnvironment } from "./runtime.ts"; | 43 | import { followStreamInEnvironment } from "./runtime.ts"; |
| 44 | import { | ||
| 45 | applyServerConfigProjection, | ||
| 46 | type ServerConfigProjection, | ||
| 47 | withoutEnvironmentThemes, | ||
| 48 | } from "./serverConfigProjection.ts"; | ||
| 49 | |||
| 50 | // Exported server state includes this type in its inferred public return type. | ||
| 51 | export type { ServerConfigProjection } from "./serverConfigProjection.ts"; | ||
| 44 | 52 | ||
| 45 | export type ServerUpdateStage = "downloading" | "installing" | "resuming"; | 53 | export type ServerUpdateStage = "downloading" | "installing" | "resuming"; |
| 46 | 54 | ||
| ... | @@ -262,75 +270,6 @@ export function resolveServerUpdateProgressResult<E>( | ... | @@ -262,75 +270,6 @@ export function resolveServerUpdateProgressResult<E>( |
| 262 | return Effect.fail(new ServerUpdateProgressIncompleteError({ targetVersion })); | 270 | return Effect.fail(new ServerUpdateProgressIncompleteError({ targetVersion })); |
| 263 | } | 271 | } |
| 264 | 272 | ||
| 265 | export interface ServerConfigProjection { | ||
| 266 | readonly config: ServerConfig; | ||
| 267 | readonly latestEvent: ServerConfigStreamEvent; | ||
| 268 | readonly source: "cache" | "live"; | ||
| 269 | } | ||
| 270 | |||
| 271 | export function applyServerConfigProjection( | ||
| 272 | current: Option.Option<ServerConfigProjection>, | ||
| 273 | event: ServerConfigStreamEvent, | ||
| 274 | ): Option.Option<ServerConfigProjection> { | ||
| 275 | switch (event.type) { | ||
| 276 | case "snapshot": { | ||
| 277 | // A snapshot never carries published themes -- the theme stream owns | ||
| 278 | // them -- so taking it wholesale would clear the set on every reconnect | ||
| 279 | // and repaint anyone wearing one until the follow-up event landed. | ||
| 280 | // Only from a server that still streams them. Reconnecting to one that | ||
| 281 | // predates the feature must drop the set rather than leave a palette on | ||
| 282 | // screen that nothing will ever update again. | ||
| 283 | const carried = | ||
| 284 | event.config.environment.capabilities.environmentThemes === true && Option.isSome(current) | ||
| 285 | ? current.value.config.environmentThemes | ||
| 286 | : undefined; | ||
| 287 | return Option.some({ | ||
| 288 | config: | ||
| 289 | carried === undefined ? event.config : { ...event.config, environmentThemes: carried }, | ||
| 290 | latestEvent: event, | ||
| 291 | source: "live" as const, | ||
| 292 | }); | ||
| 293 | } | ||
| 294 | case "keybindingsUpdated": | ||
| 295 | return Option.map(current, (projection) => ({ | ||
| 296 | config: { | ||
| 297 | ...projection.config, | ||
| 298 | keybindings: event.payload.keybindings, | ||
| 299 | issues: event.payload.issues, | ||
| 300 | }, | ||
| 301 | latestEvent: event, | ||
| 302 | source: "live", | ||
| 303 | })); | ||
| 304 | case "providerStatuses": | ||
| 305 | return Option.map(current, (projection) => ({ | ||
| 306 | config: { | ||
| 307 | ...projection.config, | ||
| 308 | providers: event.payload.providers, | ||
| 309 | }, | ||
| 310 | latestEvent: event, | ||
| 311 | source: "live", | ||
| 312 | })); | ||
| 313 | case "settingsUpdated": | ||
| 314 | return Option.map(current, (projection) => ({ | ||
| 315 | config: { | ||
| 316 | ...projection.config, | ||
| 317 | settings: event.payload.settings, | ||
| 318 | }, | ||
| 319 | latestEvent: event, | ||
| 320 | source: "live", | ||
| 321 | })); | ||
| 322 | case "environmentThemesUpdated": | ||
| 323 | return Option.map(current, (projection) => ({ | ||
| 324 | config: { | ||
| 325 | ...projection.config, | ||
| 326 | environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined, | ||
| 327 | }, | ||
| 328 | latestEvent: event, | ||
| 329 | source: "live", | ||
| 330 | })); | ||
| 331 | } | ||
| 332 | } | ||
| 333 | |||
| 334 | export function projectServerConfig( | 273 | export function projectServerConfig( |
| 335 | current: Option.Option<ServerConfigProjection>, | 274 | current: Option.Option<ServerConfigProjection>, |
| 336 | event: ServerConfigStreamEvent, | 275 | event: ServerConfigStreamEvent, |
| ... | @@ -345,22 +284,6 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven | ... | @@ -345,22 +284,6 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven |
| 345 | config, | 284 | config, |
| 346 | }); | 285 | }); |
| 347 | 286 | ||
| 348 | /** | ||
| 349 | * Keeps a complete server configuration available during reconnects. Server | ||
| 350 | * config carries the provider/model catalogue used by task creation, so it is | ||
| 351 | * useful—and safe—to retain after a transport session ends. | ||
| 352 | */ | ||
| 353 | /** | ||
| 354 | * Published themes live only as long as the machine publishes them, so they | ||
| 355 | * must not survive in the config cache: a restart or an offline load would | ||
| 356 | * otherwise hand clients palettes the environment has already dropped. | ||
| 357 | */ | ||
| 358 | function withoutEnvironmentThemes(config: ServerConfig): ServerConfig { | ||
| 359 | if (config.environmentThemes === undefined) return config; | ||
| 360 | const { environmentThemes: _ephemeral, ...rest } = config; | ||
| 361 | return rest; | ||
| 362 | } | ||
| 363 | |||
| 364 | export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( | 287 | export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( |
| 365 | function* (environmentThemes?: boolean) { | 288 | function* (environmentThemes?: boolean) { |
| 366 | const supervisor = yield* EnvironmentSupervisor; | 289 | const supervisor = yield* EnvironmentSupervisor; |
packages/client-runtime/src/state/serverConfigProjection.ts created+79| ... | @@ -0,0 +1,79 @@ | ||
| 1 | import type { ServerConfig, ServerConfigStreamEvent } from "@t3tools/contracts"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | |||
| 4 | export interface ServerConfigProjection { | ||
| 5 | readonly config: ServerConfig; | ||
| 6 | readonly latestEvent: ServerConfigStreamEvent; | ||
| 7 | readonly source: "cache" | "live"; | ||
| 8 | } | ||
| 9 | |||
| 10 | /** | ||
| 11 | * Cached config keeps the provider and model catalog available across reconnects. | ||
| 12 | * Published themes are current machine state, so a cache could restore themes | ||
| 13 | * that the machine no longer publishes. Replay sends themes as a separate event. | ||
| 14 | */ | ||
| 15 | export function withoutEnvironmentThemes(config: ServerConfig): ServerConfig { | ||
| 16 | if (config.environmentThemes === undefined) return config; | ||
| 17 | const { environmentThemes: _ephemeral, ...rest } = config; | ||
| 18 | return rest; | ||
| 19 | } | ||
| 20 | |||
| 21 | export function applyServerConfigProjection( | ||
| 22 | current: Option.Option<ServerConfigProjection>, | ||
| 23 | event: ServerConfigStreamEvent, | ||
| 24 | ): Option.Option<ServerConfigProjection> { | ||
| 25 | switch (event.type) { | ||
| 26 | case "snapshot": { | ||
| 27 | // Wire snapshots never contain published themes. Keep the previous set | ||
| 28 | // until a capable server sends its authoritative theme event. A legacy | ||
| 29 | // server cannot send a later removal, so a downgrade must clear the set. | ||
| 30 | const carried = | ||
| 31 | event.config.environment.capabilities.environmentThemes === true && Option.isSome(current) | ||
| 32 | ? current.value.config.environmentThemes | ||
| 33 | : undefined; | ||
| 34 | return Option.some({ | ||
| 35 | config: | ||
| 36 | carried === undefined ? event.config : { ...event.config, environmentThemes: carried }, | ||
| 37 | latestEvent: event, | ||
| 38 | source: "live" as const, | ||
| 39 | }); | ||
| 40 | } | ||
| 41 | case "keybindingsUpdated": | ||
| 42 | return Option.map(current, (projection) => ({ | ||
| 43 | config: { | ||
| 44 | ...projection.config, | ||
| 45 | keybindings: event.payload.keybindings, | ||
| 46 | issues: event.payload.issues, | ||
| 47 | }, | ||
| 48 | latestEvent: event, | ||
| 49 | source: "live", | ||
| 50 | })); | ||
| 51 | case "providerStatuses": | ||
| 52 | return Option.map(current, (projection) => ({ | ||
| 53 | config: { | ||
| 54 | ...projection.config, | ||
| 55 | providers: event.payload.providers, | ||
| 56 | }, | ||
| 57 | latestEvent: event, | ||
| 58 | source: "live", | ||
| 59 | })); | ||
| 60 | case "settingsUpdated": | ||
| 61 | return Option.map(current, (projection) => ({ | ||
| 62 | config: { | ||
| 63 | ...projection.config, | ||
| 64 | settings: event.payload.settings, | ||
| 65 | }, | ||
| 66 | latestEvent: event, | ||
| 67 | source: "live", | ||
| 68 | })); | ||
| 69 | case "environmentThemesUpdated": | ||
| 70 | return Option.map(current, (projection) => ({ | ||
| 71 | config: { | ||
| 72 | ...projection.config, | ||
| 73 | environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined, | ||
| 74 | }, | ||
| 75 | latestEvent: event, | ||
| 76 | source: "live", | ||
| 77 | })); | ||
| 78 | } | ||
| 79 | } | ||
packages/client-runtime/src/state/shell-sync.test.ts+1| ... | @@ -51,6 +51,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { | ... | @@ -51,6 +51,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { |
| 51 | return { | 51 | return { |
| 52 | client, | 52 | client, |
| 53 | initialConfig: Effect.succeed({ shellResumeCompletionMarker: true } as never), | 53 | initialConfig: Effect.succeed({ shellResumeCompletionMarker: true } as never), |
| 54 | subscribeServerConfig: (input) => client.subscribeServerConfig(input), | ||
| 54 | ready: Effect.void, | 55 | ready: Effect.void, |
| 55 | probe: Effect.void, | 56 | probe: Effect.void, |
| 56 | closed: Effect.never, | 57 | closed: Effect.never, |
packages/client-runtime/src/state/sourceControl.test.ts+1| ... | @@ -50,6 +50,7 @@ function session(client: WsRpcProtocolClient): RpcSession { | ... | @@ -50,6 +50,7 @@ function session(client: WsRpcProtocolClient): RpcSession { |
| 50 | return { | 50 | return { |
| 51 | client, | 51 | client, |
| 52 | initialConfig: Effect.never, | 52 | initialConfig: Effect.never, |
| 53 | subscribeServerConfig: (input) => client.subscribeServerConfig(input), | ||
| 53 | ready: Effect.void, | 54 | ready: Effect.void, |
| 54 | probe: Effect.void, | 55 | probe: Effect.void, |
| 55 | closed: Effect.never, | 56 | closed: Effect.never, |
packages/client-runtime/src/state/threads-pagination.test.ts+1| ... | @@ -156,6 +156,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt | ... | @@ -156,6 +156,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt |
| 156 | initialConfig: Effect.succeed({ | 156 | initialConfig: Effect.succeed({ |
| 157 | threadSnapshotPagination: options?.paginationCapability !== false, | 157 | threadSnapshotPagination: options?.paginationCapability !== false, |
| 158 | } as never), | 158 | } as never), |
| 159 | subscribeServerConfig: (input) => client.subscribeServerConfig(input), | ||
| 159 | ready: Effect.void, | 160 | ready: Effect.void, |
| 160 | probe: Effect.void, | 161 | probe: Effect.void, |
| 161 | closed: Effect.never, | 162 | closed: Effect.never, |
packages/client-runtime/src/state/threads-sync.test.ts+1| ... | @@ -112,6 +112,7 @@ function testSession( | ... | @@ -112,6 +112,7 @@ function testSession( |
| 112 | ? ({ threadResumeCompletionMarker: true } as never) | 112 | ? ({ threadResumeCompletionMarker: true } as never) |
| 113 | : ({} as never), | 113 | : ({} as never), |
| 114 | ), | 114 | ), |
| 115 | subscribeServerConfig: (input) => client.subscribeServerConfig(input), | ||
| 115 | ready: Effect.void, | 116 | ready: Effect.void, |
| 116 | probe: Effect.void, | 117 | probe: Effect.void, |
| 117 | closed: Effect.never, | 118 | closed: Effect.never, |
packages/client-runtime/src/state/vcs.test.ts+1| ... | @@ -86,6 +86,7 @@ function session(client: WsRpcProtocolClient): RpcSession { | ... | @@ -86,6 +86,7 @@ function session(client: WsRpcProtocolClient): RpcSession { |
| 86 | return { | 86 | return { |
| 87 | client, | 87 | client, |
| 88 | initialConfig: Effect.never, | 88 | initialConfig: Effect.never, |
| 89 | subscribeServerConfig: (input) => client.subscribeServerConfig(input), | ||
| 89 | ready: Effect.void, | 90 | ready: Effect.void, |
| 90 | probe: Effect.void, | 91 | probe: Effect.void, |
| 91 | closed: Effect.never, | 92 | closed: Effect.never, |
packages/client-runtime/src/state/vcsAction.test.ts+1| ... | @@ -84,6 +84,7 @@ function session(client: WsRpcProtocolClient): RpcSession { | ... | @@ -84,6 +84,7 @@ function session(client: WsRpcProtocolClient): RpcSession { |
| 84 | return { | 84 | return { |
| 85 | client, | 85 | client, |
| 86 | initialConfig: Effect.never, | 86 | initialConfig: Effect.never, |
| 87 | subscribeServerConfig: (input) => client.subscribeServerConfig(input), | ||
| 87 | ready: Effect.void, | 88 | ready: Effect.void, |
| 88 | probe: Effect.void, | 89 | probe: Effect.void, |
| 89 | closed: Effect.never, | 90 | closed: Effect.never, |