From 420fd76f60433fe05b8d2c76f4fbde430dc49968 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 6 Sep 2026 02:33:13 -0300 Subject: [PATCH] feat(connections): balance new threads across connected machines (#9895) --- .../settings/DesktopClientSettings.test.ts | 2 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/resourceTelemetry/HostResources.ts | 93 +++++++++++++ apps/server/src/server.test.ts | 94 ++++++++++++- apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 6 + apps/web/src/components/BranchToolbar.tsx | 52 ++++++- .../BranchToolbarBranchSelector.tsx | 6 +- .../BranchToolbarEnvironmentSelector.tsx | 46 ++++-- apps/web/src/components/ChatView.tsx | 131 ++++++++++++++++++ apps/web/src/components/GitActionsControl.tsx | 8 +- .../settings/ConnectionsSettings.tsx | 2 + .../settings/LoadBalancingSettings.tsx | 94 +++++++++++++ .../src/components/settings/settingsSearch.ts | 8 ++ apps/web/src/composerDraftStore.test.ts | 57 ++++++++ apps/web/src/composerDraftStore.ts | 59 ++++++++ .../src/hooks/useLoadBalancedEnvironment.ts | 50 +++++++ docs/user/remote-access.md | 17 +++ packages/client-runtime/package.json | 4 + packages/client-runtime/src/load-balancing.ts | 40 ++++++ .../src/state/projectGrouping.test.ts | 65 +++++++++ packages/client-runtime/src/state/server.ts | 7 + packages/contracts/src/resourceTelemetry.ts | 10 ++ packages/contracts/src/rpc.ts | 9 ++ packages/contracts/src/settings.test.ts | 15 ++ packages/contracts/src/settings.ts | 9 ++ 26 files changed, 865 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/resourceTelemetry/HostResources.ts create mode 100644 apps/web/src/components/settings/LoadBalancingSettings.tsx create mode 100644 apps/web/src/hooks/useLoadBalancedEnvironment.ts create mode 100644 packages/client-runtime/src/load-balancing.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 9fbacc832a90e931ffef2ef571133d02d4672db9..89e1a7fb19e37ed84b9521484450732f9b6a773c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -58,6 +58,8 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, legacySidebarEnabled: false, + loadBalancingEnabled: false, + loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 7bd1ed6c45f1436ee2557ef92c8a98cd17f246d8..a069322aa8bf9ba327a998e623ce823f779593d4 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -53,6 +53,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts new file mode 100644 index 0000000000000000000000000000000000000000..032832dd4869d0bcca0a59ab88ad9aca32f6f1ce --- /dev/null +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -0,0 +1,93 @@ +import * as NodeOS from "node:os"; +import type { HostResourcesSnapshot } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export class HostResources extends Context.Service< + HostResources, + { readonly read: Effect.Effect } +>()("t3/resourceTelemetry/HostResources") {} + +function readCpu() { + const cpus = NodeOS.cpus(); + const cpu = cpus.reduce( + (sum, { times }) => ({ + idle: sum.idle + times.idle, + total: sum.total + times.user + times.nice + times.sys + times.idle + times.irq, + }), + { idle: 0, total: 0 }, + ); + return { ...cpu, count: cpus.length }; +} + +function darwinAvailableMemory(output: string): number | null { + const pageSize = /page size of (\d+) bytes/.exec(output)?.[1]; + const free = /^Pages free:\s+(\d+)\./m.exec(output)?.[1]; + const inactive = /^Pages inactive:\s+(\d+)\./m.exec(output)?.[1]; + const speculative = /^Pages speculative:\s+(\d+)\./m.exec(output)?.[1]; + if (!pageSize || !free || !inactive || !speculative) return null; + // vm_stat subtracts speculative pages from its printed "Pages free" count. + // Adding them here counts each reclaimable page once; purgeable pages overlap. + const available = (Number(free) + Number(inactive) + Number(speculative)) * Number(pageSize); + return Number.isSafeInteger(available) && Number(pageSize) > 0 ? available : null; +} + +export const make = Effect.fn("makeHostResources")(function* () { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const sample = Effect.fn("HostResources.sample")(function* () { + const previousCpu = readCpu(); + // CPU counters need two readings; idle servers do no polling or process scans. + yield* Effect.sleep("200 millis"); + const cpu = readCpu(); + const totalDelta = cpu.total - previousCpu.total; + const idleDelta = cpu.idle - previousCpu.idle; + const cpuUtilization = + previousCpu.count === cpu.count && totalDelta > 0 && idleDelta >= 0 + ? Math.min(1, Math.max(0, 1 - idleDelta / totalDelta)) + : null; + const totalMemoryBytes = NodeOS.totalmem(); + // On Windows libuv returns GlobalMemoryStatusEx.ullAvailPhys, including standby memory. + let availableMemoryBytes = NodeOS.freemem(); + if (platform === "linux") { + const meminfo = yield* fs + .readFileString("/proc/meminfo") + .pipe(Effect.catch(() => Effect.succeed(""))); + const available = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1]; + if (available) availableMemoryBytes = Number(available) * 1024; + } else if (platform === "darwin") { + const output = yield* spawner + .string(ChildProcess.make("/usr/bin/vm_stat", [], { stdin: "ignore", stderr: "ignore" })) + .pipe( + Effect.timeout("1 second"), + Effect.catch(() => Effect.succeed("")), + ); + availableMemoryBytes = darwinAvailableMemory(output) ?? availableMemoryBytes; + } + return { + sampledAt: DateTime.toEpochMillis(yield* DateTime.now), + cpuUtilization, + cpuCount: cpu.count, + availableMemoryBytes: Math.min(totalMemoryBytes, Math.max(0, availableMemoryBytes)), + totalMemoryBytes, + }; + }); + + // One server-lifetime cache deduplicates simultaneous requests from all sockets. + const cache = yield* Cache.make({ + capacity: 1, + lookup: (_key: "host") => sample(), + timeToLive: "5 seconds", + }); + return HostResources.of({ read: Cache.get(cache, "host") }); +}); + +export const layer = Layer.effect(HostResources, make()); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 56889c1e63b2d833b7addc8e14c805b1b92c5328..64bfea1b9805c6280d1c8fd41f590027bac91a76 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -165,6 +165,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; @@ -845,7 +846,8 @@ const buildAppUnderTest = (options?: { }), }), ), - Layer.provide( + Layer.provide([ + HostResources.layer, Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({ readHistory: (input) => Effect.succeed({ @@ -860,7 +862,7 @@ const buildAppUnderTest = (options?: { error: Option.none(), }), }), - ), + ]), Layer.provide( Layer.mock(TraceDiagnostics.TraceDiagnostics)({ read: () => @@ -6154,6 +6156,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("returns cached whole-host resources over websocket", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const wsUrl = yield* getWsServerUrl("/ws"); + const [first, second] = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.all( + [ + client[WS_METHODS.serverGetHostResources]({}), + client[WS_METHODS.serverGetHostResources]({}), + ], + { concurrency: "unbounded" }, + ), + ), + ); + assert.deepEqual(first, second); + assert.isAtLeast(first.sampledAt, 0); + assert.isAbove(first.cpuCount, 0); + assert.isAbove(first.totalMemoryBytes, 0); + assert.isAtLeast(first.availableMemoryBytes, 0); + assert.isAtMost(first.availableMemoryBytes, first.totalMemoryBytes); + if (first.cpuUtilization !== null) { + assert.isAtLeast(first.cpuUtilization, 0); + assert.isAtMost(first.cpuUtilization, 1); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("counts macOS reclaimable memory once and shares concurrent samples", () => + Effect.gen(function* () { + const commandCalls = yield* Ref.make(0); + const hostResources = yield* HostResources.make().pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provide( + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ + string: () => + Ref.update(commandCalls, (count) => count + 1).pipe( + Effect.as( + "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n" + + "Pages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n" + + "Pages purgeable: 999.\n", + ), + ), + }), + ), + ); + const [first, second] = yield* Effect.all([hostResources.read, hostResources.read], { + concurrency: "unbounded", + }); + assert.equal(first.availableMemoryBytes, 35 * 16384); + assert.deepEqual(first, second); + assert.deepEqual(yield* hostResources.read, first); + assert.equal(yield* Ref.get(commandCalls), 1); + }).pipe(TestClock.withLive), + ); + + it.effect("retries host sampling immediately after its caller is interrupted", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const commandCalls = yield* Ref.make(0); + const hostResources = yield* HostResources.make().pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provide( + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ + string: () => + Effect.gen(function* () { + const call = yield* Ref.updateAndGet(commandCalls, (count) => count + 1); + if (call === 1) { + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + } + return ( + "Mach Virtual Memory Statistics: (page size of 4096 bytes)\n" + + "Pages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n" + ); + }), + }), + ), + ); + const firstRead = yield* hostResources.read.pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Fiber.interrupt(firstRead); + const recovered = yield* hostResources.read; + assert.equal(recovered.availableMemoryBytes, 35 * 4096); + assert.equal(yield* Ref.get(commandCalls), 2); + }).pipe(TestClock.withLive), + ); + it.effect("routes websocket resource telemetry through the subscription", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4abe43d8a63139ea11a0340c9db52092b60c806d..ce39ee64f51a565ede6ae9dfc5cd5abc07135415 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -115,6 +115,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; @@ -199,6 +200,7 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); const ResourceDiagnosticsLayerLive = Layer.mergeAll( + HostResources.layer, ResourceTelemetryLayerLive, ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fa29d5bd9847dff40769cecb14ffe2edc49c4961..b4255c647816152d1bc676ce4e6332ed4d6c7e49 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -135,6 +135,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as UsageService from "./usage/UsageService.ts"; @@ -605,6 +606,7 @@ const makeWsRpcLayer = ( const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; + const hostResources = yield* HostResources.HostResources; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; @@ -2020,6 +2022,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetHostResources]: (_input) => + observeRpcEffect(WS_METHODS.serverGetHostResources, hostResources.read, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverGetProcessResourceHistory]: (input) => observeRpcEffect( WS_METHODS.serverGetProcessResourceHistory, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 07407bcf21b39fb92104c3a90a113dbfe7427759..7ad86106ae082d10bf7370fd3f18dd4924f9e6c5 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -6,6 +6,7 @@ import { FolderGitIcon, FolderIcon, HistoryIcon, + ScaleIcon, } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -56,6 +57,8 @@ interface BranchToolbarProps { onActiveThreadBranchOverrideChange?: (branch: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -66,6 +69,8 @@ interface BranchToolbarProps { } interface MobileRunContextSelectorProps { + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; envModeLocked: boolean; environmentId: EnvironmentId; @@ -81,6 +86,8 @@ interface MobileRunContextSelectorProps { } const MobileRunContextSelector = memo(function MobileRunContextSelector({ + autoEnvironmentLabel, + onAutoEnvironment, envLocked, envModeLocked, environmentId, @@ -114,10 +121,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. - + {autoEnvironmentLabel ? ( + ) : ( @@ -134,7 +145,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ data-composer-label-motion className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity" > - {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + {autoEnvironmentLabel ?? + (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)} @@ -167,9 +179,29 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ Run on onEnvironmentChange(value as EnvironmentId)} + value={autoEnvironmentLabel ? "auto" : environmentId} + onValueChange={(value) => + value === "auto" + ? onAutoEnvironment?.() + : onEnvironmentChange(value as EnvironmentId) + } > + {onAutoEnvironment && ( + { + if (autoEnvironmentLabel) onAutoEnvironment?.(); + }} + > + + + + )} {availableEnvironments.map((env) => ( { + (branch: string | null, worktreePath: string | null, automatic = false) => { if (!activeThreadId || !activeProject) return; if (serverSession && worktreePath !== activeWorktreePath) { void stopThreadSession({ @@ -186,6 +186,7 @@ export function BranchToolbarBranchSelector({ branch, worktreePath, envMode: nextDraftEnvMode, + environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual", projectRef: scopeProjectRef(environmentId, activeProject.id), }); }, @@ -201,6 +202,7 @@ export function BranchToolbarBranchSelector({ threadRef, environmentId, effectiveEnvMode, + draftThread?.environmentSelection, stopThreadSession, updateThreadMetadata, ], @@ -507,7 +509,7 @@ export function BranchToolbarBranchSelector({ ) { return; } - setThreadBranch(worktreeBaseBranchCandidate, null); + setThreadBranch(worktreeBaseBranchCandidate, null, true); }, [ activeThreadBranch, activeWorktreePath, diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 431805f2174b0e8a1f036148871bcd7043d3a6ad..863cc7312fe6066ec8a7e526520cfc36ea473221 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -1,4 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; +import { ScaleIcon } from "lucide-react"; import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; @@ -15,6 +16,8 @@ import { } from "./ui/select"; interface BranchToolbarEnvironmentSelectorProps { + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; @@ -24,6 +27,8 @@ interface BranchToolbarEnvironmentSelectorProps { } export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ + autoEnvironmentLabel, + onAutoEnvironment, envLocked, environmentId, availableEnvironments, @@ -34,12 +39,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir }, [availableEnvironments, environmentId]); const environmentItems = useMemo( - () => - availableEnvironments.map((env) => ({ + () => [ + ...(onAutoEnvironment + ? [{ value: "auto", label: autoEnvironmentLabel ?? "Auto balance" }] + : []), + ...availableEnvironments.map((env) => ({ value: env.environmentId, label: env.label, })), - [availableEnvironments], + ], + [availableEnvironments, autoEnvironmentLabel, onAutoEnvironment], ); // The static label carries the xs control's height (h-7 sm:h-6) as well as @@ -75,8 +84,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir return ( + } + /> + ); + })} + + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index f715f6ca4e6d4482ff071aabbd54a65ffccf9454..dd4e357eb9d7a35732422b647985544a6da2270e 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -514,6 +514,14 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/connections", searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"], }, + { + id: "load-balancing", + title: "Load balancing", + to: "/settings/connections", + searchTerms: [ + "automatic machine environment resources cpu memory capacity preference weight shared projects", + ], + }, { id: "archive", title: "Archived threads", diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 53d07aab21d9fe65541c6124836767b82fc6e942..4a682b97910deb3c251159916312cd479158b995 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1623,6 +1623,63 @@ describe("composerDraftStore project draft thread mapping", () => { expect(file && composerFileNeedsReattach(file)).toBe(true); }); + it("rechecks balancing when an empty draft is remapped to another project member", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + environmentSelection: "auto", + loadBalancedEnvironmentId: TEST_ENVIRONMENT_ID, + }); + store.setProjectDraftThreadId(remoteProjectRef, draftId, { threadId }); + expect(store.getDraftThread(draftId)).toMatchObject({ + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + store.setDraftThreadContext(draftId, { loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID }); + store.setDraftThreadContext(draftId, { projectRef }); + expect(store.getDraftThread(draftId)).toMatchObject({ + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + }); + + it("does not opt a legacy branch choice into balancing when runtime mode changes", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId, branch: "feature/pinned" }); + store.setDraftThreadContext(draftId, { runtimeMode: "full-access" }); + expect(store.getDraftThread(draftId)?.environmentSelection).toBeUndefined(); + expect(store.getDraftThread(draftId)?.branch).toBe("feature/pinned"); + }); + + it("pins manual workspace choices and can return to automatic routing without losing the prompt", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "keep this prompt"); + store.setDraftThreadContext(draftId, { + projectRef: remoteProjectRef, + environmentSelection: "auto", + loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID, + }); + expect(store.getDraftThread(draftId)).toMatchObject({ + environmentId: OTHER_TEST_ENVIRONMENT_ID, + environmentSelection: "auto", + loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID, + }); + store.setDraftThreadContext(draftId, { branch: "feature/pinned" }); + expect(store.getDraftThread(draftId)?.environmentSelection).toBe("manual"); + store.setDraftThreadContext(draftId, { + branch: null, + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + expect(store.getDraftThread(draftId)).toMatchObject({ + branch: null, + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt"); + }); + it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 9dbc7b9b5c21a4c4f17b106d1159297143c11666..5bdd823b4d88bec53447af4e54946ab3a7b5ba36 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -315,6 +315,8 @@ const PersistedDraftThreadState = Schema.Struct({ environmentId: Schema.String, projectId: ProjectId, logicalProjectKey: Schema.optionalKey(Schema.String), + environmentSelection: Schema.optionalKey(Schema.Literals(["auto", "manual"])), + loadBalancedEnvironmentId: Schema.optionalKey(Schema.NullOr(Schema.String)), createdAt: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, @@ -427,6 +429,8 @@ export interface DraftSessionState { environmentId: EnvironmentId; projectId: ProjectId; logicalProjectKey: string; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; createdAt: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; @@ -503,6 +507,8 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ) => void; /** Creates or updates the draft session tracked for a concrete project ref. */ @@ -518,6 +524,8 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ) => void; /** Updates mutable draft-session metadata without touching composer content. */ @@ -532,6 +540,8 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ) => void; clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; @@ -1534,6 +1544,8 @@ function createDraftThreadState( startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ): DraftThreadState { // A project change (including switching environments within a logical @@ -1560,11 +1572,23 @@ function createDraftThreadState( options?.startFromOrigin === undefined ? (existingThread?.startFromOrigin ?? false) : options.startFromOrigin; + const environmentSelection = + options?.environmentSelection ?? existingThread?.environmentSelection; return { threadId, environmentId: projectRef.environmentId, projectId: projectRef.projectId, logicalProjectKey, + ...(environmentSelection ? { environmentSelection } : {}), + ...(options?.loadBalancedEnvironmentId !== undefined + ? { loadBalancedEnvironmentId: options.loadBalancedEnvironmentId } + : existingThread?.loadBalancedEnvironmentId !== undefined + ? { + loadBalancedEnvironmentId: projectChanged + ? null + : existingThread.loadBalancedEnvironmentId, + } + : {}), createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, interactionMode: @@ -1599,6 +1623,8 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.environmentId === right.environmentId && left.projectId === right.projectId && left.logicalProjectKey === right.logicalProjectKey && + left.environmentSelection === right.environmentSelection && + left.loadBalancedEnvironmentId === right.loadBalancedEnvironmentId && left.createdAt === right.createdAt && left.runtimeMode === right.runtimeMode && left.interactionMode === right.interactionMode && @@ -1754,6 +1780,16 @@ function normalizePersistedDraftThreads( worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), startFromOrigin, + ...(candidateDraftThread.environmentSelection === "manual" || + candidateDraftThread.environmentSelection === "auto" + ? { environmentSelection: candidateDraftThread.environmentSelection } + : {}), + ...(typeof candidateDraftThread.loadBalancedEnvironmentId === "string" && + candidateDraftThread.loadBalancedEnvironmentId.length > 0 + ? { loadBalancedEnvironmentId: candidateDraftThread.loadBalancedEnvironmentId } + : candidateDraftThread.loadBalancedEnvironmentId === null + ? { loadBalancedEnvironmentId: null } + : {}), promotedTo, }; } @@ -2453,6 +2489,15 @@ function toHydratedDraftThreadState( worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, startFromOrigin: persistedDraftThread.startFromOrigin, + ...(persistedDraftThread.environmentSelection + ? { environmentSelection: persistedDraftThread.environmentSelection } + : {}), + ...(persistedDraftThread.loadBalancedEnvironmentId !== undefined + ? { + loadBalancedEnvironmentId: + persistedDraftThread.loadBalancedEnvironmentId as EnvironmentId | null, + } + : {}), promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2707,11 +2752,23 @@ const composerDraftStore = create()( options.startFromOrigin === undefined ? existing.startFromOrigin : options.startFromOrigin; + const environmentSelection = + options.environmentSelection ?? + (options.branch != null || options.worktreePath != null + ? "manual" + : existing.environmentSelection); const nextDraftThread: DraftThreadState = { threadId: existing.threadId, environmentId: nextProjectRef.environmentId, projectId: nextProjectRef.projectId, logicalProjectKey: existing.logicalProjectKey, + ...(environmentSelection ? { environmentSelection } : {}), + loadBalancedEnvironmentId: + options.loadBalancedEnvironmentId === undefined + ? projectChanged + ? null + : (existing.loadBalancedEnvironmentId ?? null) + : options.loadBalancedEnvironmentId, createdAt: options.createdAt === undefined ? existing.createdAt @@ -2729,6 +2786,8 @@ const composerDraftStore = create()( nextDraftThread.environmentId === existing.environmentId && nextDraftThread.projectId === existing.projectId && nextDraftThread.logicalProjectKey === existing.logicalProjectKey && + nextDraftThread.environmentSelection === existing.environmentSelection && + nextDraftThread.loadBalancedEnvironmentId === existing.loadBalancedEnvironmentId && nextDraftThread.createdAt === existing.createdAt && nextDraftThread.runtimeMode === existing.runtimeMode && nextDraftThread.interactionMode === existing.interactionMode && diff --git a/apps/web/src/hooks/useLoadBalancedEnvironment.ts b/apps/web/src/hooks/useLoadBalancedEnvironment.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d3ddfb8b1efb3b2f317ae8a1aae6cb03af2d924 --- /dev/null +++ b/apps/web/src/hooks/useLoadBalancedEnvironment.ts @@ -0,0 +1,50 @@ +import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import { chooseLoadBalancedEnvironment } from "@t3tools/client-runtime/load-balancing"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { useCallback, useContext, useMemo } from "react"; + +import { serverEnvironment } from "../state/server"; + +/** Only mounted for unresolved automatic drafts, so idle clients do not poll hosts. */ +export function useLoadBalancedEnvironment( + environmentIds: readonly EnvironmentId[], + weights: Readonly>, +) { + const registry = useContext(RegistryContext); + const refresh = useCallback( + (ids: readonly EnvironmentId[]) => { + for (const environmentId of ids) { + registry.refresh(serverEnvironment.hostResources({ environmentId, input: {} })); + } + }, + [registry], + ); + const resourcesAtom = useMemo( + () => + Atom.make((get) => + environmentIds.map((environmentId) => { + const result = get(serverEnvironment.hostResources({ environmentId, input: {} })); + return { + environmentId, + resources: result._tag === "Success" ? result.value : null, + receivedAt: result._tag === "Success" ? result.timestamp : 0, + pending: result._tag === "Initial" || result.waiting, + }; + }), + ), + [environmentIds], + ); + const resources = useAtomValue(resourcesAtom); + return { + refresh, + pending: resources.some((resource) => resource.pending), + environmentId: chooseLoadBalancedEnvironment( + resources.map((resource) => ({ + ...resource, + weight: weights[resource.environmentId] ?? 50, + })), + Date.now(), + ) as EnvironmentId | null, + }; +} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index ec2724b4b04eb2d6889248c679e76225814897c5..b10123ab6223d974c9ced82252dd88a139872b6f 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -61,6 +61,23 @@ created in Settings can only be copied from the client that created them while its Connections page stays open. If you leave or reload that page, create another link to share. +### Balance new threads across machines + +Auto balance is off by default. On web and desktop, enable it in +**Settings → Connections → Load balancing** to automatically choose a machine for +new threads in projects grouped across connected environments. +Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and +memory available, **Less often** to reduce its share, or **Manual only** to exclude +it from automatic selection. These are preferences, not fixed traffic percentages. +Preferences are saved separately in each client. + +The composer checks eligible machines when choosing a draft's environment, then keeps +that choice stable. Choose **Auto balance** again to check current resources, or choose +a specific machine to override it. Choosing a branch or worktree also keeps the draft +on that machine. Existing threads stay where they started. If resource checks are +unavailable or all eligible machines are full, choose a machine manually to continue. +Mobile keeps its manual environment selection. + ### Tailscale HTTPS Join both devices to the same tailnet. In the desktop app, enable **Tailscale diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 7409a194a2fde8fafb4085552d1c5b38c02a0487..775a4a898f8f9b3fac7df7ed672ad0dcd9c3878e 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./load-balancing": { + "types": "./src/load-balancing.ts", + "default": "./src/load-balancing.ts" + }, "./project-favicon-cache": { "types": "./src/projectFaviconCache.ts", "default": "./src/projectFaviconCache.ts" diff --git a/packages/client-runtime/src/load-balancing.ts b/packages/client-runtime/src/load-balancing.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b938c6c090e458c0544d9421d1b9a459c0f0b8a --- /dev/null +++ b/packages/client-runtime/src/load-balancing.ts @@ -0,0 +1,40 @@ +import type { HostResourcesSnapshot } from "@t3tools/contracts"; + +/** Callers supply only connected machines hosting the project and selected provider. */ +export function chooseLoadBalancedEnvironment( + candidates: ReadonlyArray<{ + environmentId: string; + resources: HostResourcesSnapshot | null; + /** Client receipt time avoids comparing clocks on different machines. */ + receivedAt?: number; + weight: number; + }>, + now: number, +): string | null { + let selected: string | null = null; + let bestScore = 0; + for (const { environmentId, resources, receivedAt, weight } of candidates) { + const sampledAt = receivedAt ?? resources?.sampledAt ?? 0; + if ( + !resources || + !Number.isFinite(weight) || + weight <= 0 || + now - sampledAt > 15_000 || + sampledAt > now + 5_000 || + resources.cpuUtilization === null || + resources.cpuUtilization >= 0.95 || + resources.totalMemoryBytes <= 0 || + resources.cpuCount <= 0 + ) { + continue; + } + const memoryAvailable = resources.availableMemoryBytes / resources.totalMemoryBytes; + if (memoryAvailable <= 0.05) continue; + const score = weight * resources.cpuCount * (1 - resources.cpuUtilization) * memoryAvailable; + if (score > bestScore) { + selected = environmentId; + bestScore = score; + } + } + return selected; +} diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 94d213b257b62e7ee1ff6911d0e29429617253de..4884c3b99bbcfc574e6b02fe4d7b4cbc70efa722 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "./models.ts"; +import { chooseLoadBalancedEnvironment } from "../load-balancing.ts"; import { buildProjectGroups, derivePhysicalProjectKey, @@ -9,6 +10,70 @@ import { } from "./projectGrouping.ts"; const environmentId = EnvironmentId.make("environment"); + +describe("load balancing shared project machines", () => { + const now = 100_000; + const resources = { + sampledAt: now, + cpuUtilization: 0.2, + cpuCount: 8, + availableMemoryBytes: 8_000, + totalMemoryBytes: 16_000, + }; + + it("compares three machines using free capacity and preference", () => { + const candidates = [ + { environmentId: "busy", resources: { ...resources, cpuUtilization: 0.9 }, weight: 1 }, + { environmentId: "idle", resources, weight: 1 }, + { environmentId: "preferred", resources: { ...resources, cpuCount: 4 }, weight: 3 }, + ]; + expect(chooseLoadBalancedEnvironment(candidates, now)).toBe("preferred"); + expect(chooseLoadBalancedEnvironment(candidates.slice(0, 2), now)).toBe("idle"); + }); + + it("rejects stale, unknown, excluded and saturated machines", () => { + expect( + chooseLoadBalancedEnvironment( + [ + { + environmentId: "stale", + resources: { ...resources, sampledAt: now - 15_001 }, + weight: 1, + }, + { environmentId: "unknown", resources: null, weight: 1 }, + { + environmentId: "no-cpu-sample", + resources: { ...resources, cpuUtilization: null }, + weight: 1, + }, + { environmentId: "excluded", resources, weight: 0 }, + { + environmentId: "cpu-full", + resources: { ...resources, cpuUtilization: 0.95 }, + weight: 1, + }, + { + environmentId: "memory-full", + resources: { ...resources, availableMemoryBytes: 100 }, + weight: 1, + }, + ], + now, + ), + ).toBeNull(); + }); + + it("uses client receipt time when host clocks differ", () => { + const candidate = { + environmentId: "different-clock", + resources: { ...resources, sampledAt: now + 60_000 }, + receivedAt: now, + weight: 1, + }; + expect(chooseLoadBalancedEnvironment([candidate], now)).toBe("different-clock"); + expect(chooseLoadBalancedEnvironment([candidate], now + 15_001)).toBeNull(); + }); +}); const repositoryIdentity = { canonicalKey: "github.com/t3tools/t3code", locator: { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 5df29a8629d47569b1284642653f400d6da16f75..911ee1bd85c65c8d5bed451af924b9c632b31bb5 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -26,6 +26,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { createAtomCommandScheduler, createEnvironmentRpcCommand, + createEnvironmentQueryAtomFamily, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, createRuntimeCommand, @@ -1018,6 +1019,12 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-diagnostics", tag: WS_METHODS.serverGetProcessDiagnostics, }), + hostResources: createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:server:host-resources", + staleTimeMs: 5_000, + execute: (input: EnvironmentRpcInput) => + request(WS_METHODS.serverGetHostResources, input).pipe(Effect.timeout("5 seconds")), + }), processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index 3ec1e4de3ef489cdffcfa5605150e8798c3289a4..87b52993a56b7a7b091b6cac7f31eefb66a930b4 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -6,6 +6,16 @@ import { DesktopUpdateStateSchema } from "./ipc.ts"; export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const; +/** Whole-host capacity, independent of T3's process diagnostics. */ +export const HostResourcesSnapshot = Schema.Struct({ + sampledAt: NonNegativeInt, + cpuUtilization: Schema.NullOr(Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))), + cpuCount: NonNegativeInt, + availableMemoryBytes: NonNegativeInt, + totalMemoryBytes: NonNegativeInt, +}); +export type HostResourcesSnapshot = typeof HostResourcesSnapshot.Type; + export const ResourceTelemetryIoSemantics = Schema.Literals([ "storage", "logical", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 12c653f70cc472d4c307066bb2b828eff913d93e..fb077193c20226d23300e67d77e75f59f30df7e0 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -210,6 +210,7 @@ import { ServerUpsertKeybindingResult, } from "./server.ts"; import { + HostResourcesSnapshot, ResourceTelemetryHistory, ResourceTelemetryHistoryInput, ResourceTelemetryRetryResult, @@ -323,6 +324,7 @@ export const WS_METHODS = { serverDiscoverSourceControl: "server.discoverSourceControl", serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", + serverGetHostResources: "server.getHostResources", serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", serverRetryResourceTelemetry: "server.retryResourceTelemetry", @@ -538,6 +540,12 @@ const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDia error: EnvironmentAuthorizationError, }); +const WsServerGetHostResourcesRpc = Rpc.make(WS_METHODS.serverGetHostResources, { + payload: Schema.Struct({}), + success: HostResourcesSnapshot, + error: EnvironmentAuthorizationError, +}); + const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, { payload: ServerProcessResourceHistoryInput, success: ServerProcessResourceHistoryResult, @@ -1197,6 +1205,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerDiscoverSourceControlRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, + WsServerGetHostResourcesRpc, WsServerGetProcessResourceHistoryRpc, WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1673f4ad159b9b4f7810c5d8c9021e7b0b8b6bc6..7c497bacfa8993c9bdaacd9c7df877e17cc3bc0a 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -135,6 +135,21 @@ describe("ClaudeSettings auto-compaction", () => { }); }); +describe("ClientSettings load balancing", () => { + it("requires opt-in when settings are new or omit load balancing", () => { + expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false); + expect(decodeClientSettings({ loadBalancingWeights: {} }).loadBalancingEnabled).toBe(false); + }); + + it.each([true, false])("preserves a saved choice of %s", (loadBalancingEnabled) => { + const settings = decodeClientSettings({ loadBalancingEnabled }); + expect(encodeClientSettings(settings).loadBalancingEnabled).toBe(loadBalancingEnabled); + expect(decodeClientSettingsPatch({ loadBalancingEnabled }).loadBalancingEnabled).toBe( + loadBalancingEnabled, + ); + }); +}); + describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { expect(decodeClientSettings({}).wordWrap).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 983e17b543709976797432c9d7f7883458d1b40f..1ddf66cde63ca05b8d0d6fec3353c099979a3501 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -204,7 +204,14 @@ export const BrowserLinkTarget = Schema.Literals(["system", "app"]); export type BrowserLinkTarget = typeof BrowserLinkTarget.Type; export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system"; +export const LoadBalancingWeights = Schema.Record( + TrimmedNonEmptyString, + Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })), +); + export const ClientSettingsSchema = Schema.Struct({ + loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))), appearanceContrast: AppearanceContrast.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)), ), @@ -1210,6 +1217,8 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ + loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), + loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), appearanceContrast: Schema.optionalKey(AppearanceContrast), panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs), browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting), -- 2.54.0