diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index 8b4997f695d15..fff8a8168437b 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -7,7 +7,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { makeStaleWhileRevalidate } from "./staleWhileRevalidate.ts"; + const SSH_PORT = 22; +const TARGETS_CACHE_TTL = "60 seconds"; export class RemoteOpenTargets extends Context.Service< RemoteOpenTargets, @@ -21,7 +24,7 @@ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const net = yield* NetService.NetService; - const resolveTargets = Effect.gen(function* () { + const discoverTargets = Effect.gen(function* () { // Check both loopback families: sshd can be bound IPv6-only. const sshdListening = yield* Effect.zipWith( net.hasListenerOnHost(SSH_PORT, "127.0.0.1"), @@ -78,6 +81,10 @@ export const make = Effect.gen(function* () { return targets; }); + // Advertised on every client connect; probing sshd and tailscaled is slow + // on a loaded host, so serve the last result while it refreshes. + const resolveTargets = yield* makeStaleWhileRevalidate(discoverTargets, TARGETS_CACHE_TTL); + return RemoteOpenTargets.of({ resolveTargets: Effect.fn("RemoteOpenTargets.resolveTargets")(function () { return resolveTargets; diff --git a/apps/server/src/environment/staleWhileRevalidate.test.ts b/apps/server/src/environment/staleWhileRevalidate.test.ts new file mode 100644 index 0000000000000..6fa0faa9c4cbe --- /dev/null +++ b/apps/server/src/environment/staleWhileRevalidate.test.ts @@ -0,0 +1,177 @@ +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as TestClock from "effect/testing/TestClock"; + +import { makeStaleWhileRevalidate } from "./staleWhileRevalidate.ts"; + +it.effect("computes once, then answers from memory until the ttl lapses", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const read = yield* makeStaleWhileRevalidate( + Ref.updateAndGet(calls, (count) => count + 1), + "60 seconds", + ); + + assert.equal(yield* read, 1); + yield* TestClock.adjust("59 seconds"); + assert.equal(yield* read, 1); + assert.equal(yield* Ref.get(calls), 1); + }).pipe(Effect.scoped), +); + +it.effect("serves the stale value while one background refresh replaces it", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const release = yield* Deferred.make(); + const started = yield* Deferred.make(); + const refreshed = yield* Deferred.make>(); + const read = yield* makeStaleWhileRevalidate( + Effect.gen(function* () { + const call = yield* Ref.updateAndGet(calls, (count) => count + 1); + if (call > 1) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + yield* Deferred.succeed(refreshed, yield* Effect.fiber); + } + return call; + }), + "60 seconds", + ); + + assert.equal(yield* read, 1); + yield* TestClock.adjust("61 seconds"); + // The refresh is blocked, yet callers are answered immediately and only + // one refresh starts however many callers arrive. + assert.equal(yield* read, 1); + assert.equal(yield* read, 1); + yield* Deferred.await(started); + assert.equal(yield* Ref.get(calls), 2); + + yield* Deferred.succeed(release, undefined); + yield* Fiber.await(yield* Deferred.await(refreshed)); + assert.equal(yield* read, 2); + }), +); + +it.effect("shares one first scan that an interrupted caller does not cancel", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const release = yield* Deferred.make(); + const started = yield* Deferred.make(); + const read = yield* makeStaleWhileRevalidate( + Ref.updateAndGet(calls, (count) => count + 1).pipe( + Effect.tap(() => Deferred.succeed(started, undefined)), + Effect.tap(() => Deferred.await(release)), + ), + "60 seconds", + ); + + // A connect that times out or disconnects mid-scan leaves the scan running. + const interrupted = yield* read.pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Fiber.interrupt(interrupted); + + const next = yield* read.pipe(Effect.forkChild); + yield* Deferred.succeed(release, undefined); + assert.equal(yield* Fiber.join(next), 1); + assert.equal(yield* Ref.get(calls), 1); + }).pipe(Effect.scoped), +); + +it.effect("starts over after a failed first scan", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const read = yield* makeStaleWhileRevalidate( + Ref.updateAndGet(calls, (count) => count + 1).pipe( + Effect.filterOrElse( + (call) => call !== 1, + () => Effect.die("probe crashed"), + ), + ), + "60 seconds", + ); + + assert.isTrue(Exit.isFailure(yield* Effect.exit(read))); + assert.equal(yield* read, 2); + }).pipe(Effect.scoped), +); + +it.effect("abandons a hung refresh so a later call can start another", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const started = yield* Deferred.make>(); + const interrupted = yield* Deferred.make(); + const refreshed = yield* Deferred.make>(); + const read = yield* makeStaleWhileRevalidate( + Ref.updateAndGet(calls, (count) => count + 1).pipe( + Effect.flatMap((call) => + call === 2 + ? Effect.fiber.pipe( + Effect.flatMap((fiber) => Deferred.succeed(started, fiber)), + Effect.andThen(Effect.never), + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), + ) + : Effect.succeed(call).pipe( + Effect.tap(() => + call === 3 + ? Effect.fiber.pipe( + Effect.flatMap((fiber) => Deferred.succeed(refreshed, fiber)), + ) + : Effect.void, + ), + ), + ), + ), + "60 seconds", + ); + + assert.equal(yield* read, 1); + yield* TestClock.adjust("61 seconds"); + assert.equal(yield* read, 1); + yield* Deferred.await(started); + assert.equal(yield* Ref.get(calls), 2); + + // The hung refresh times out, releasing the claim for the next caller. + yield* TestClock.adjust("30 seconds"); + yield* Deferred.await(interrupted); + yield* Fiber.await(yield* Deferred.await(started)); + assert.equal(yield* read, 1); + yield* Fiber.await(yield* Deferred.await(refreshed)); + assert.equal(yield* Ref.get(calls), 3); + assert.equal(yield* read, 3); + }).pipe(Effect.scoped), +); + +it.effect("interrupts a background refresh when the owning scope closes", () => + Effect.gen(function* () { + const interrupted = yield* Deferred.make(); + const started = yield* Deferred.make(); + const scope = yield* Scope.make(); + const calls = yield* Ref.make(0); + const read = yield* makeStaleWhileRevalidate( + Ref.updateAndGet(calls, (count) => count + 1).pipe( + Effect.filterOrElse( + (call) => call === 1, + () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), + ), + ), + ), + "60 seconds", + ).pipe(Scope.provide(scope)); + + yield* read; + yield* TestClock.adjust("61 seconds"); + yield* read; + yield* Deferred.await(started); + yield* Scope.close(scope, Exit.void); + yield* Deferred.await(interrupted); + }), +); diff --git a/apps/server/src/environment/staleWhileRevalidate.ts b/apps/server/src/environment/staleWhileRevalidate.ts new file mode 100644 index 0000000000000..e87757bee7031 --- /dev/null +++ b/apps/server/src/environment/staleWhileRevalidate.ts @@ -0,0 +1,81 @@ +import * as Clock from "effect/Clock"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Ref from "effect/Ref"; + +const REFRESH_TIMEOUT = Duration.seconds(30); + +type Entry = + | { readonly _tag: "Empty" } + | { readonly _tag: "Scanning"; readonly scan: Deferred.Deferred } + | { + readonly _tag: "Ready"; + readonly value: A; + readonly expiresAtNanos: bigint; + readonly refreshing: boolean; + }; + +// Shared scans survive caller cancellation; monotonic TTL bounds cache age. +// Warm reads return stale data while a scope-owned, bounded refresh runs. +export const makeStaleWhileRevalidate = Effect.fn("StaleWhileRevalidate.make")(function* ( + discover: Effect.Effect, + ttl: Duration.Input, +) { + const ttlNanos = Duration.toNanosUnsafe(Duration.fromInputUnsafe(ttl)); + const scope = yield* Effect.scope; + const state = yield* Ref.make>({ _tag: "Empty" }); + + const settle = Effect.fn("StaleWhileRevalidate.settle")(function* (exit: Exit.Exit) { + const now = yield* Clock.monotonicTimeNanos; + yield* Ref.update(state, (entry): Entry => + Exit.isSuccess(exit) + ? { + _tag: "Ready", + value: exit.value, + expiresAtNanos: now + ttlNanos, + refreshing: false, + } + : entry._tag === "Ready" + ? { ...entry, refreshing: false } + : { _tag: "Empty" }, + ); + }); + const fork = (scan: Effect.Effect) => + scan.pipe(Effect.ignoreCause({ log: true }), Effect.interruptible, Effect.forkIn(scope)); + const firstScan = (scan: Deferred.Deferred) => + fork( + discover.pipe( + Effect.onExit((exit) => settle(exit).pipe(Effect.andThen(Deferred.done(scan, exit)))), + ), + ); + const refresh = fork(discover.pipe(Effect.onExit(settle), Effect.timeoutOption(REFRESH_TIMEOUT))); + + return Effect.gen(function* () { + const now = yield* Clock.monotonicTimeNanos; + // Claiming a scan and starting it are one uninterruptible step: a caller + // interrupted between them would leave a claimed scan that never runs. + const result = yield* Ref.modify( + state, + (entry): readonly [readonly [Effect.Effect, Effect.Effect], Entry] => { + switch (entry._tag) { + case "Empty": { + const scan = Deferred.makeUnsafe(); + return [[firstScan(scan), Deferred.await(scan)], { _tag: "Scanning", scan }]; + } + case "Scanning": + return [[Effect.void, Deferred.await(entry.scan)], entry]; + case "Ready": + return entry.refreshing || entry.expiresAtNanos > now + ? [[Effect.void, Effect.succeed(entry.value)], entry] + : [[refresh, Effect.succeed(entry.value)], { ...entry, refreshing: true }]; + } + }, + ).pipe( + Effect.flatMap(([start, result]) => Effect.as(start, result)), + Effect.uninterruptible, + ); + return yield* result; + }); +}); diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 3262c555bcf8c..1b724c17010d8 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -5,6 +5,7 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -714,6 +715,79 @@ it.effect.skipIf(windowsHost)("ignores unusable app bundles and keeps PATH launc }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect( + "memoizes editor discovery and refreshes in the background after the cache window", + () => { + let statCalls = 0; + const rescanned = Deferred.makeUnsafe(); + let initialScanDone = false; + const fileInfo = { type: "File" } as FileSystem.File.Info; + const layerLauncher = ExternalLauncher.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + stat: () => + Effect.sync(() => { + statCalls += 1; + return fileInfo; + }).pipe( + Effect.tap(() => + initialScanDone ? Deferred.succeed(rescanned, undefined) : Effect.void, + ), + ), + }), + Path.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), + ), + ), + ), + ); + + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + + const first = yield* launcher.resolveAvailableEditors(); + assert.equal(first.includes("vscode"), true); + const statCallsAfterFirstScan = statCalls; + initialScanDone = true; + assert.isAbove(statCallsAfterFirstScan, 0); + + // Past the shared command-resolution cache TTL (30s) but within the + // discovery cache window: the memoized set is reused without any scan. + yield* TestClock.adjust("31 seconds"); + const second = yield* launcher.resolveAvailableEditors(); + assert.deepEqual([...second], [...first]); + assert.equal(statCalls, statCallsAfterFirstScan); + + // Past the discovery cache window the next call still answers from the + // memoized set and rescans in the background. + yield* TestClock.adjust("30 seconds"); + const third = yield* launcher.resolveAvailableEditors(); + assert.deepEqual([...third], [...first]); + yield* Deferred.await(rescanned); + assert.isAbove(statCalls, statCallsAfterFirstScan); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerLauncher, + Layer.succeed(HostProcess.Platform, "win32"), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + PATH: "C:\\t3-editor-discovery-cache-test", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ), + TestClock.layer(), + ), + ), + ); + }, +); + it.effect("keeps scanning after the caller is interrupted and shares that scan", () => { const fileInfo = { type: "File" } as FileSystem.File.Info; const release = Deferred.makeUnsafe(); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 6a2eb145162e2..7f4417d1fb67a 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -19,24 +19,20 @@ import { resolveSpawnCommand, withPathDirectoryListings, } from "@t3tools/shared/shell"; -import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Base64 from "effect/encoding/Base64"; -import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { makeStaleWhileRevalidate } from "../environment/staleWhileRevalidate.ts"; export { ExternalLauncherError, ExternalLauncherBrowserSpawnError, @@ -447,13 +443,7 @@ const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileMana }, ); -// Shared scans must survive caller timeouts/disconnects; monotonic expiry prevents clock rollback extending stale entries. -const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; - -interface EditorDiscoveryCacheEntry { - readonly scan: Deferred.Deferred>; - readonly expiresAtNanos: bigint | undefined; -} +const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds"; export class ExternalLauncher extends Context.Service< ExternalLauncher, @@ -817,69 +807,27 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); - const scope = yield* Scope.Scope; - const editorDiscoveryCache = yield* Ref.make>( - Option.none(), - ); - const runEditorDiscovery = (scan: Deferred.Deferred>) => + const cachedAvailableEditors = yield* makeStaleWhileRevalidate( provideCommandResolutionServices(resolveAvailableEditors()).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.onExit((exit) => - Effect.gen(function* () { - const expiresAtNanos = - (yield* Clock.monotonicTimeNanos) + EDITOR_DISCOVERY_CACHE_TTL_NANOS; - yield* Ref.update(editorDiscoveryCache, (current) => - Option.isNone(current) || current.value.scan !== scan - ? current - : Exit.isSuccess(exit) - ? Option.some({ scan, expiresAtNanos }) - : Option.none(), - ); - yield* Deferred.done(scan, exit); - }), - ), - Effect.interruptible, - Effect.forkIn(scope), - ); - // Claim and start atomically; interruption between them would leave an entry waiting on no scan. - const acquireEditorDiscovery = Effect.gen(function* () { - const nowNanos = yield* Clock.monotonicTimeNanos; - const [scan, isNewScan] = yield* Ref.modify( - editorDiscoveryCache, - ( - current, - ): [ - [EditorDiscoveryCacheEntry["scan"], boolean], - Option.Option, - ] => { - if ( - Option.isSome(current) && - (current.value.expiresAtNanos === undefined || current.value.expiresAtNanos > nowNanos) - ) { - return [[current.value.scan, false], current]; - } - const scan = Deferred.makeUnsafe>(); - return [[scan, true], Option.some({ scan, expiresAtNanos: undefined })]; - }, - ); - if (isNewScan) { - yield* runEditorDiscovery(scan); - } - return scan; - }).pipe(Effect.uninterruptible); - const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await); + ), + EDITOR_DISCOVERY_CACHE_TTL, + ); + const cachedFileManagerRevealKind = yield* makeStaleWhileRevalidate( + provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + EDITOR_DISCOVERY_CACHE_TTL, + ); // Forwarders retain the underlying launcher and discovery spans. return ExternalLauncher.of({ resolveAvailableEditors: Effect.fnUntraced(function* () { return yield* cachedAvailableEditors; }), - resolveFileManagerRevealKind: Effect.fnUntraced( - function* () { - return yield* provideCommandResolutionServices(resolveFileManagerRevealKind()); - }, - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), + resolveFileManagerRevealKind: Effect.fnUntraced(function* () { + return yield* cachedFileManagerRevealKind; + }), launchBrowser: Effect.fnUntraced( function* (target) { return yield* launchBrowser(target); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 641845f322ea6..ba647514ecef4 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -123,6 +123,7 @@ import * as ProjectEnrichment from "./project/ProjectEnrichmentService.ts"; import * as ProjectService from "./project/ProjectService.ts"; import { layerWsRpc, + resolveOpenDiscoveryForConfig, subscribeOrchestrationV2Shell, subscribeOrchestrationV2Thread, WS_RPC_SERVER_OPTIONS, @@ -188,6 +189,38 @@ it.effect("resends a file manager reveal kind that missed the snapshot", () => }), ); +it.effect("runs editor, remote open target and direct endpoint discovery side by side", () => + Effect.gen(function* () { + // Each discovery finishes only once the other two have started, so a + // config that ran them one after another would never resolve. + const editorsStarted = yield* Deferred.make(); + const targetsStarted = yield* Deferred.make(); + const endpointsStarted = yield* Deferred.make(); + const startThenAwait = (started: Deferred.Deferred, value: A) => + Deferred.succeed(started, undefined).pipe( + Effect.andThen( + Effect.forEach([editorsStarted, targetsStarted, endpointsStarted], Deferred.await), + ), + Effect.as(value), + ); + + const discovery = yield* resolveOpenDiscoveryForConfig({ + editors: startThenAwait(editorsStarted, ["file-manager" as const]), + fileManagerRevealKind: Effect.succeed("finder" as const), + remoteOpenTargets: startThenAwait(targetsStarted, []), + directEndpoints: startThenAwait(endpointsStarted, []), + }); + + assert.deepEqual(discovery, { + availableEditors: ["file-manager"], + shellRevealInFileManager: true, + shellRevealInFileManagerKind: "finder", + remoteOpenTargets: [], + directEndpoints: [], + }); + }), +); + const ReplayTestLayer = OrchestrationEventStore.layer.pipe( Layer.provideMerge(SqlitePersistence.layerMemory), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4781fcf78dde6..8373bbfae0c55 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -324,7 +324,8 @@ export const withLateEditorConfig = ( editors.join() !== config.availableEditors.join() || (editors.includes("file-manager") && config.shellRevealInFileManagerKind === undefined), ), - // Leave the unshared reveal probe unbounded so timeouts cannot cancel every attempt on slow hosts. + // Unbounded, unlike the snapshot: this exists to deliver a probe that + // outlasts the snapshot's timeout. Stream.mapEffect((editors) => resolveEditorConfig(editors, launcher.resolveFileManagerRevealKind()), ), @@ -366,6 +367,31 @@ export const withLateEditorConfig = ( ); }; +// Discoveries run side by side so the slowest one, not their sum, bounds the +// server config snapshot. Each degrades on timeout rather than failing it. +export const resolveOpenDiscoveryForConfig = (discovery: { + readonly editors: Effect.Effect, E1, R1>; + readonly fileManagerRevealKind: Effect.Effect; + readonly remoteOpenTargets: Effect.Effect, E3, R3>; + readonly directEndpoints: Effect.Effect, E4, R4>; +}) => + Effect.all( + { + editorConfig: resolveAvailableEditorsForConfig(discovery.editors).pipe( + Effect.flatMap((availableEditors) => + resolveEditorConfig( + availableEditors, + resolveFileManagerRevealKindForConfig(discovery.fileManagerRevealKind), + ), + ), + ), + // Same discovery-with-timeout treatment as editors: a slow probe must + // not stall server.getConfig, so it degrades to an empty list. + remoteOpenTargets: resolveAvailableEditorsForConfig(discovery.remoteOpenTargets), + directEndpoints: resolveAvailableEditorsForConfig(discovery.directEndpoints), + }, + { concurrency: "unbounded" }, + ).pipe(Effect.map(({ editorConfig, ...endpoints }) => ({ ...editorConfig, ...endpoints }))); function withoutWorkspaceSnapshots(providers: ReadonlyArray): ServerProvider[] { return providers.map(({ workspaceSnapshots: _workspaceSnapshots, ...provider }) => provider); } @@ -1744,10 +1770,12 @@ export const layerWsRpc = ( const auth = yield* serverAuth.getDescriptor(); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; const lastEventLoopStall = yield* eventLoopMonitor.latestNoticeableStall; - const editorConfig = yield* resolveEditorConfig( - yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), - resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), - ); + const discovery = yield* resolveOpenDiscoveryForConfig({ + editors: externalLauncher.resolveAvailableEditors(), + fileManagerRevealKind: externalLauncher.resolveFileManagerRevealKind(), + remoteOpenTargets: remoteOpenTargets.resolveTargets(), + directEndpoints: directEndpoints.resolve(), + }); return { environment, @@ -1757,11 +1785,7 @@ export const layerWsRpc = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - ...editorConfig, - remoteOpenTargets: yield* resolveAvailableEditorsForConfig( - remoteOpenTargets.resolveTargets(), - ), - directEndpoints: yield* resolveAvailableEditorsForConfig(directEndpoints.resolve()), + ...discovery, observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true,