| author | |
| committer | |
| log | 420fd76f60433fe05b8d2c76f4fbde430dc49968 |
| tree | 04aec1843d95a1873aff936760a7ade23c0e4b15 |
| parent | 9f40b2f563c662b43887b11ff99c466fe871c1af |
| signature | Signed by PGP key B5690EEEBB952194 |
26 files changed, 865 insertions(+), 22 deletions(-)
apps/desktop/src/settings/DesktopClientSettings.test.ts+2| ... | ... | @@ -58,6 +58,8 @@ const clientSettings: ClientSettings = { |
| 58 | 58 | sidebarThreadSortOrder: "created_at", |
| 59 | 59 | sidebarThreadPreviewCount: 6, |
| 60 | 60 | legacySidebarEnabled: false, |
| 61 | loadBalancingEnabled: false, | |
| 62 | loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, | |
| 61 | 63 | timestampFormat: "24-hour", |
| 62 | 64 | wordWrap: true, |
| 63 | 65 | }; |
apps/server/src/auth/RpcAuthorization.ts+1| ... | ... | @@ -53,6 +53,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 53 | 53 | [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, |
| 54 | 54 | [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, |
| 55 | 55 | [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, |
| 56 | [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, | |
| 56 | 57 | [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, |
| 57 | 58 | [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, |
| 58 | 59 | [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, |
apps/server/src/resourceTelemetry/HostResources.ts created+93| ... | ... | @@ -0,0 +1,93 @@ |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | import type { HostResourcesSnapshot } from "@t3tools/contracts"; | |
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | import * as Cache from "effect/Cache"; | |
| 5 | import * as Context from "effect/Context"; | |
| 6 | import * as DateTime from "effect/DateTime"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as FileSystem from "effect/FileSystem"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 11 | ||
| 12 | export class HostResources extends Context.Service< | |
| 13 | HostResources, | |
| 14 | { readonly read: Effect.Effect<HostResourcesSnapshot> } | |
| 15 | >()("t3/resourceTelemetry/HostResources") {} | |
| 16 | ||
| 17 | function readCpu() { | |
| 18 | const cpus = NodeOS.cpus(); | |
| 19 | const cpu = cpus.reduce( | |
| 20 | (sum, { times }) => ({ | |
| 21 | idle: sum.idle + times.idle, | |
| 22 | total: sum.total + times.user + times.nice + times.sys + times.idle + times.irq, | |
| 23 | }), | |
| 24 | { idle: 0, total: 0 }, | |
| 25 | ); | |
| 26 | return { ...cpu, count: cpus.length }; | |
| 27 | } | |
| 28 | ||
| 29 | function darwinAvailableMemory(output: string): number | null { | |
| 30 | const pageSize = /page size of (\d+) bytes/.exec(output)?.[1]; | |
| 31 | const free = /^Pages free:\s+(\d+)\./m.exec(output)?.[1]; | |
| 32 | const inactive = /^Pages inactive:\s+(\d+)\./m.exec(output)?.[1]; | |
| 33 | const speculative = /^Pages speculative:\s+(\d+)\./m.exec(output)?.[1]; | |
| 34 | if (!pageSize || !free || !inactive || !speculative) return null; | |
| 35 | // vm_stat subtracts speculative pages from its printed "Pages free" count. | |
| 36 | // Adding them here counts each reclaimable page once; purgeable pages overlap. | |
| 37 | const available = (Number(free) + Number(inactive) + Number(speculative)) * Number(pageSize); | |
| 38 | return Number.isSafeInteger(available) && Number(pageSize) > 0 ? available : null; | |
| 39 | } | |
| 40 | ||
| 41 | export const make = Effect.fn("makeHostResources")(function* () { | |
| 42 | const fs = yield* FileSystem.FileSystem; | |
| 43 | const platform = yield* HostProcessPlatform; | |
| 44 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 45 | ||
| 46 | const sample = Effect.fn("HostResources.sample")(function* () { | |
| 47 | const previousCpu = readCpu(); | |
| 48 | // CPU counters need two readings; idle servers do no polling or process scans. | |
| 49 | yield* Effect.sleep("200 millis"); | |
| 50 | const cpu = readCpu(); | |
| 51 | const totalDelta = cpu.total - previousCpu.total; | |
| 52 | const idleDelta = cpu.idle - previousCpu.idle; | |
| 53 | const cpuUtilization = | |
| 54 | previousCpu.count === cpu.count && totalDelta > 0 && idleDelta >= 0 | |
| 55 | ? Math.min(1, Math.max(0, 1 - idleDelta / totalDelta)) | |
| 56 | : null; | |
| 57 | const totalMemoryBytes = NodeOS.totalmem(); | |
| 58 | // On Windows libuv returns GlobalMemoryStatusEx.ullAvailPhys, including standby memory. | |
| 59 | let availableMemoryBytes = NodeOS.freemem(); | |
| 60 | if (platform === "linux") { | |
| 61 | const meminfo = yield* fs | |
| 62 | .readFileString("/proc/meminfo") | |
| 63 | .pipe(Effect.catch(() => Effect.succeed(""))); | |
| 64 | const available = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1]; | |
| 65 | if (available) availableMemoryBytes = Number(available) * 1024; | |
| 66 | } else if (platform === "darwin") { | |
| 67 | const output = yield* spawner | |
| 68 | .string(ChildProcess.make("/usr/bin/vm_stat", [], { stdin: "ignore", stderr: "ignore" })) | |
| 69 | .pipe( | |
| 70 | Effect.timeout("1 second"), | |
| 71 | Effect.catch(() => Effect.succeed("")), | |
| 72 | ); | |
| 73 | availableMemoryBytes = darwinAvailableMemory(output) ?? availableMemoryBytes; | |
| 74 | } | |
| 75 | return { | |
| 76 | sampledAt: DateTime.toEpochMillis(yield* DateTime.now), | |
| 77 | cpuUtilization, | |
| 78 | cpuCount: cpu.count, | |
| 79 | availableMemoryBytes: Math.min(totalMemoryBytes, Math.max(0, availableMemoryBytes)), | |
| 80 | totalMemoryBytes, | |
| 81 | }; | |
| 82 | }); | |
| 83 | ||
| 84 | // One server-lifetime cache deduplicates simultaneous requests from all sockets. | |
| 85 | const cache = yield* Cache.make({ | |
| 86 | capacity: 1, | |
| 87 | lookup: (_key: "host") => sample(), | |
| 88 | timeToLive: "5 seconds", | |
| 89 | }); | |
| 90 | return HostResources.of({ read: Cache.get(cache, "host") }); | |
| 91 | }); | |
| 92 | ||
| 93 | export const layer = Layer.effect(HostResources, make()); |
apps/server/src/server.test.ts+92-2| ... | ... | @@ -165,6 +165,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; |
| 165 | 165 | import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; |
| 166 | 166 | import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; |
| 167 | 167 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 168 | import * as HostResources from "./resourceTelemetry/HostResources.ts"; | |
| 168 | 169 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 169 | 170 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 170 | 171 | import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; |
| ... | ... | @@ -845,7 +846,8 @@ const buildAppUnderTest = (options?: { |
| 845 | 846 | }), |
| 846 | 847 | }), |
| 847 | 848 | ), |
| 848 | Layer.provide( | |
| 849 | Layer.provide([ | |
| 850 | HostResources.layer, | |
| 849 | 851 | Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({ |
| 850 | 852 | readHistory: (input) => |
| 851 | 853 | Effect.succeed({ |
| ... | ... | @@ -860,7 +862,7 @@ const buildAppUnderTest = (options?: { |
| 860 | 862 | error: Option.none(), |
| 861 | 863 | }), |
| 862 | 864 | }), |
| 863 | ), | |
| 865 | ]), | |
| 864 | 866 | Layer.provide( |
| 865 | 867 | Layer.mock(TraceDiagnostics.TraceDiagnostics)({ |
| 866 | 868 | read: () => |
| ... | ... | @@ -6154,6 +6156,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 6154 | 6156 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 6155 | 6157 | ); |
| 6156 | 6158 | |
| 6159 | it.effect("returns cached whole-host resources over websocket", () => | |
| 6160 | Effect.gen(function* () { | |
| 6161 | yield* buildAppUnderTest(); | |
| 6162 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 6163 | const [first, second] = yield* Effect.scoped( | |
| 6164 | withWsRpcClient(wsUrl, (client) => | |
| 6165 | Effect.all( | |
| 6166 | [ | |
| 6167 | client[WS_METHODS.serverGetHostResources]({}), | |
| 6168 | client[WS_METHODS.serverGetHostResources]({}), | |
| 6169 | ], | |
| 6170 | { concurrency: "unbounded" }, | |
| 6171 | ), | |
| 6172 | ), | |
| 6173 | ); | |
| 6174 | assert.deepEqual(first, second); | |
| 6175 | assert.isAtLeast(first.sampledAt, 0); | |
| 6176 | assert.isAbove(first.cpuCount, 0); | |
| 6177 | assert.isAbove(first.totalMemoryBytes, 0); | |
| 6178 | assert.isAtLeast(first.availableMemoryBytes, 0); | |
| 6179 | assert.isAtMost(first.availableMemoryBytes, first.totalMemoryBytes); | |
| 6180 | if (first.cpuUtilization !== null) { | |
| 6181 | assert.isAtLeast(first.cpuUtilization, 0); | |
| 6182 | assert.isAtMost(first.cpuUtilization, 1); | |
| 6183 | } | |
| 6184 | }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), | |
| 6185 | ); | |
| 6186 | ||
| 6187 | it.effect("counts macOS reclaimable memory once and shares concurrent samples", () => | |
| 6188 | Effect.gen(function* () { | |
| 6189 | const commandCalls = yield* Ref.make(0); | |
| 6190 | const hostResources = yield* HostResources.make().pipe( | |
| 6191 | Effect.provideService(HostProcessPlatform, "darwin"), | |
| 6192 | Effect.provide( | |
| 6193 | Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ | |
| 6194 | string: () => | |
| 6195 | Ref.update(commandCalls, (count) => count + 1).pipe( | |
| 6196 | Effect.as( | |
| 6197 | "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n" + | |
| 6198 | "Pages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n" + | |
| 6199 | "Pages purgeable: 999.\n", | |
| 6200 | ), | |
| 6201 | ), | |
| 6202 | }), | |
| 6203 | ), | |
| 6204 | ); | |
| 6205 | const [first, second] = yield* Effect.all([hostResources.read, hostResources.read], { | |
| 6206 | concurrency: "unbounded", | |
| 6207 | }); | |
| 6208 | assert.equal(first.availableMemoryBytes, 35 * 16384); | |
| 6209 | assert.deepEqual(first, second); | |
| 6210 | assert.deepEqual(yield* hostResources.read, first); | |
| 6211 | assert.equal(yield* Ref.get(commandCalls), 1); | |
| 6212 | }).pipe(TestClock.withLive), | |
| 6213 | ); | |
| 6214 | ||
| 6215 | it.effect("retries host sampling immediately after its caller is interrupted", () => | |
| 6216 | Effect.gen(function* () { | |
| 6217 | const started = yield* Deferred.make<void>(); | |
| 6218 | const commandCalls = yield* Ref.make(0); | |
| 6219 | const hostResources = yield* HostResources.make().pipe( | |
| 6220 | Effect.provideService(HostProcessPlatform, "darwin"), | |
| 6221 | Effect.provide( | |
| 6222 | Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ | |
| 6223 | string: () => | |
| 6224 | Effect.gen(function* () { | |
| 6225 | const call = yield* Ref.updateAndGet(commandCalls, (count) => count + 1); | |
| 6226 | if (call === 1) { | |
| 6227 | yield* Deferred.succeed(started, undefined); | |
| 6228 | return yield* Effect.never; | |
| 6229 | } | |
| 6230 | return ( | |
| 6231 | "Mach Virtual Memory Statistics: (page size of 4096 bytes)\n" + | |
| 6232 | "Pages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n" | |
| 6233 | ); | |
| 6234 | }), | |
| 6235 | }), | |
| 6236 | ), | |
| 6237 | ); | |
| 6238 | const firstRead = yield* hostResources.read.pipe(Effect.forkChild); | |
| 6239 | yield* Deferred.await(started); | |
| 6240 | yield* Fiber.interrupt(firstRead); | |
| 6241 | const recovered = yield* hostResources.read; | |
| 6242 | assert.equal(recovered.availableMemoryBytes, 35 * 4096); | |
| 6243 | assert.equal(yield* Ref.get(commandCalls), 2); | |
| 6244 | }).pipe(TestClock.withLive), | |
| 6245 | ); | |
| 6246 | ||
| 6157 | 6247 | it.effect("routes websocket resource telemetry through the subscription", () => |
| 6158 | 6248 | Effect.gen(function* () { |
| 6159 | 6249 | yield* buildAppUnderTest(); |
apps/server/src/server.ts+2| ... | ... | @@ -115,6 +115,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; |
| 115 | 115 | import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts"; |
| 116 | 116 | import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; |
| 117 | 117 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 118 | import * as HostResources from "./resourceTelemetry/HostResources.ts"; | |
| 118 | 119 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 119 | 120 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 120 | 121 | import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; |
| ... | ... | @@ -199,6 +200,7 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( |
| 199 | 200 | const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); |
| 200 | 201 | |
| 201 | 202 | const ResourceDiagnosticsLayerLive = Layer.mergeAll( |
| 203 | HostResources.layer, | |
| 202 | 204 | ResourceTelemetryLayerLive, |
| 203 | 205 | ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), |
| 204 | 206 | ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), |
apps/server/src/ws.ts+6| ... | ... | @@ -135,6 +135,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; |
| 135 | 135 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 136 | 136 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 137 | 137 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; |
| 138 | import * as HostResources from "./resourceTelemetry/HostResources.ts"; | |
| 138 | 139 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; |
| 139 | 140 | import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; |
| 140 | 141 | import * as UsageService from "./usage/UsageService.ts"; |
| ... | ... | @@ -605,6 +606,7 @@ const makeWsRpcLayer = ( |
| 605 | 606 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| 606 | 607 | const sessions = yield* SessionStore.SessionStore; |
| 607 | 608 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; |
| 609 | const hostResources = yield* HostResources.HostResources; | |
| 608 | 610 | const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; |
| 609 | 611 | const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; |
| 610 | 612 | const usage = yield* UsageService.UsageService; |
| ... | ... | @@ -2020,6 +2022,10 @@ const makeWsRpcLayer = ( |
| 2020 | 2022 | observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, { |
| 2021 | 2023 | "rpc.aggregate": "server", |
| 2022 | 2024 | }), |
| 2025 | [WS_METHODS.serverGetHostResources]: (_input) => | |
| 2026 | observeRpcEffect(WS_METHODS.serverGetHostResources, hostResources.read, { | |
| 2027 | "rpc.aggregate": "server", | |
| 2028 | }), | |
| 2023 | 2029 | [WS_METHODS.serverGetProcessResourceHistory]: (input) => |
| 2024 | 2030 | observeRpcEffect( |
| 2025 | 2031 | WS_METHODS.serverGetProcessResourceHistory, |
apps/web/src/components/BranchToolbar.tsx+45-7| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | FolderGitIcon, |
| 7 | 7 | FolderIcon, |
| 8 | 8 | HistoryIcon, |
| 9 | ScaleIcon, | |
| 9 | 10 | } from "lucide-react"; |
| 10 | 11 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 11 | 12 | |
| ... | ... | @@ -56,6 +57,8 @@ interface BranchToolbarProps { |
| 56 | 57 | onActiveThreadBranchOverrideChange?: (branch: string | null) => void; |
| 57 | 58 | startFromOrigin: boolean; |
| 58 | 59 | onStartFromOriginChange: (startFromOrigin: boolean) => void; |
| 60 | autoEnvironmentLabel?: string | undefined; | |
| 61 | onAutoEnvironment?: (() => void) | undefined; | |
| 59 | 62 | envLocked: boolean; |
| 60 | 63 | onCheckoutPullRequestRequest?: (reference: string) => void; |
| 61 | 64 | onComposerFocusRequest?: () => void; |
| ... | ... | @@ -66,6 +69,8 @@ interface BranchToolbarProps { |
| 66 | 69 | } |
| 67 | 70 | |
| 68 | 71 | interface MobileRunContextSelectorProps { |
| 72 | autoEnvironmentLabel?: string | undefined; | |
| 73 | onAutoEnvironment?: (() => void) | undefined; | |
| 69 | 74 | envLocked: boolean; |
| 70 | 75 | envModeLocked: boolean; |
| 71 | 76 | environmentId: EnvironmentId; |
| ... | ... | @@ -81,6 +86,8 @@ interface MobileRunContextSelectorProps { |
| 81 | 86 | } |
| 82 | 87 | |
| 83 | 88 | const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 89 | autoEnvironmentLabel, | |
| 90 | onAutoEnvironment, | |
| 84 | 91 | envLocked, |
| 85 | 92 | envModeLocked, |
| 86 | 93 | environmentId, |
| ... | ... | @@ -114,10 +121,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 114 | 121 | // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px |
| 115 | 122 | // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. |
| 116 | 123 | <span className="inline-flex shrink-0 items-center gap-0.5"> |
| 117 | <EnvironmentMachineIcon | |
| 118 | kind={activeEnvironment?.machine ?? "server"} | |
| 119 | className="size-3 shrink-0 mx-0!" | |
| 120 | /> | |
| 124 | {autoEnvironmentLabel ? ( | |
| 125 | <ScaleIcon className="size-3 shrink-0 mx-0!" aria-hidden="true" /> | |
| 126 | ) : ( | |
| 127 | <EnvironmentMachineIcon | |
| 128 | kind={activeEnvironment?.machine ?? "server"} | |
| 129 | className="size-3 shrink-0 mx-0!" | |
| 130 | /> | |
| 131 | )} | |
| 121 | 132 | <WorkspaceIcon className="size-3 shrink-0 mx-0!" /> |
| 122 | 133 | </span> |
| 123 | 134 | ) : ( |
| ... | ... | @@ -134,7 +145,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 134 | 145 | data-composer-label-motion |
| 135 | 146 | 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" |
| 136 | 147 | > |
| 137 | {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} | |
| 148 | {autoEnvironmentLabel ?? | |
| 149 | (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)} | |
| 138 | 150 | </span> |
| 139 | 151 | </span> |
| 140 | 152 | </> |
| ... | ... | @@ -167,9 +179,29 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 167 | 179 | <MenuGroup> |
| 168 | 180 | <MenuGroupLabel>Run on</MenuGroupLabel> |
| 169 | 181 | <MenuRadioGroup |
| 170 | value={environmentId} | |
| 171 | onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} | |
| 182 | value={autoEnvironmentLabel ? "auto" : environmentId} | |
| 183 | onValueChange={(value) => | |
| 184 | value === "auto" | |
| 185 | ? onAutoEnvironment?.() | |
| 186 | : onEnvironmentChange(value as EnvironmentId) | |
| 187 | } | |
| 172 | 188 | > |
| 189 | {onAutoEnvironment && ( | |
| 190 | <MenuRadioItem | |
| 191 | value="auto" | |
| 192 | disabled={envLocked} | |
| 193 | onClick={() => { | |
| 194 | if (autoEnvironmentLabel) onAutoEnvironment?.(); | |
| 195 | }} | |
| 196 | > | |
| 197 | <span className="flex min-w-0 items-center gap-1.5"> | |
| 198 | <ScaleIcon className="size-3" aria-hidden="true" /> | |
| 199 | <span className="min-w-0 truncate"> | |
| 200 | {autoEnvironmentLabel ?? "Auto balance"} | |
| 201 | </span> | |
| 202 | </span> | |
| 203 | </MenuRadioItem> | |
| 204 | )} | |
| 173 | 205 | {availableEnvironments.map((env) => ( |
| 174 | 206 | <MenuRadioItem |
| 175 | 207 | key={env.environmentId} |
| ... | ... | @@ -421,6 +453,8 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 421 | 453 | onActiveThreadBranchOverrideChange, |
| 422 | 454 | startFromOrigin, |
| 423 | 455 | onStartFromOriginChange, |
| 456 | autoEnvironmentLabel, | |
| 457 | onAutoEnvironment, | |
| 424 | 458 | envLocked, |
| 425 | 459 | onCheckoutPullRequestRequest, |
| 426 | 460 | onComposerFocusRequest, |
| ... | ... | @@ -518,6 +552,8 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 518 | 552 | {showGitControls ? ( |
| 519 | 553 | <div className="contents @3xl/composer-surface:hidden"> |
| 520 | 554 | <MobileRunContextSelector |
| 555 | autoEnvironmentLabel={autoEnvironmentLabel} | |
| 556 | onAutoEnvironment={onAutoEnvironment} | |
| 521 | 557 | envLocked={envLocked} |
| 522 | 558 | envModeLocked={envModeLocked} |
| 523 | 559 | environmentId={environmentId} |
| ... | ... | @@ -544,6 +580,8 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 544 | 580 | {showEnvironmentIndicator && availableEnvironments && ( |
| 545 | 581 | <> |
| 546 | 582 | <BranchToolbarEnvironmentSelector |
| 583 | autoEnvironmentLabel={autoEnvironmentLabel} | |
| 584 | onAutoEnvironment={onAutoEnvironment} | |
| 547 | 585 | envLocked={envLocked} |
| 548 | 586 | environmentId={environmentId} |
| 549 | 587 | availableEnvironments={availableEnvironments} |
apps/web/src/components/BranchToolbarBranchSelector.tsx+4-2| ... | ... | @@ -155,7 +155,7 @@ export function BranchToolbarBranchSelector({ |
| 155 | 155 | // Thread branch mutation (colocated — only this component calls it) |
| 156 | 156 | // --------------------------------------------------------------------------- |
| 157 | 157 | const setThreadBranch = useCallback( |
| 158 | (branch: string | null, worktreePath: string | null) => { | |
| 158 | (branch: string | null, worktreePath: string | null, automatic = false) => { | |
| 159 | 159 | if (!activeThreadId || !activeProject) return; |
| 160 | 160 | if (serverSession && worktreePath !== activeWorktreePath) { |
| 161 | 161 | void stopThreadSession({ |
| ... | ... | @@ -186,6 +186,7 @@ export function BranchToolbarBranchSelector({ |
| 186 | 186 | branch, |
| 187 | 187 | worktreePath, |
| 188 | 188 | envMode: nextDraftEnvMode, |
| 189 | environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual", | |
| 189 | 190 | projectRef: scopeProjectRef(environmentId, activeProject.id), |
| 190 | 191 | }); |
| 191 | 192 | }, |
| ... | ... | @@ -201,6 +202,7 @@ export function BranchToolbarBranchSelector({ |
| 201 | 202 | threadRef, |
| 202 | 203 | environmentId, |
| 203 | 204 | effectiveEnvMode, |
| 205 | draftThread?.environmentSelection, | |
| 204 | 206 | stopThreadSession, |
| 205 | 207 | updateThreadMetadata, |
| 206 | 208 | ], |
| ... | ... | @@ -507,7 +509,7 @@ export function BranchToolbarBranchSelector({ |
| 507 | 509 | ) { |
| 508 | 510 | return; |
| 509 | 511 | } |
| 510 | setThreadBranch(worktreeBaseBranchCandidate, null); | |
| 512 | setThreadBranch(worktreeBaseBranchCandidate, null, true); | |
| 511 | 513 | }, [ |
| 512 | 514 | activeThreadBranch, |
| 513 | 515 | activeWorktreePath, |
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+37-9| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { ScaleIcon } from "lucide-react"; | |
| 2 | 3 | import { memo, useMemo } from "react"; |
| 3 | 4 | |
| 4 | 5 | import type { EnvironmentOption } from "./BranchToolbar.logic"; |
| ... | ... | @@ -15,6 +16,8 @@ import { |
| 15 | 16 | } from "./ui/select"; |
| 16 | 17 | |
| 17 | 18 | interface BranchToolbarEnvironmentSelectorProps { |
| 19 | autoEnvironmentLabel?: string | undefined; | |
| 20 | onAutoEnvironment?: (() => void) | undefined; | |
| 18 | 21 | envLocked: boolean; |
| 19 | 22 | environmentId: EnvironmentId; |
| 20 | 23 | availableEnvironments: readonly EnvironmentOption[]; |
| ... | ... | @@ -24,6 +27,8 @@ interface BranchToolbarEnvironmentSelectorProps { |
| 24 | 27 | } |
| 25 | 28 | |
| 26 | 29 | export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ |
| 30 | autoEnvironmentLabel, | |
| 31 | onAutoEnvironment, | |
| 27 | 32 | envLocked, |
| 28 | 33 | environmentId, |
| 29 | 34 | availableEnvironments, |
| ... | ... | @@ -34,12 +39,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 34 | 39 | }, [availableEnvironments, environmentId]); |
| 35 | 40 | |
| 36 | 41 | const environmentItems = useMemo( |
| 37 | () => | |
| 38 | availableEnvironments.map((env) => ({ | |
| 42 | () => [ | |
| 43 | ...(onAutoEnvironment | |
| 44 | ? [{ value: "auto", label: autoEnvironmentLabel ?? "Auto balance" }] | |
| 45 | : []), | |
| 46 | ...availableEnvironments.map((env) => ({ | |
| 39 | 47 | value: env.environmentId, |
| 40 | 48 | label: env.label, |
| 41 | 49 | })), |
| 42 | [availableEnvironments], | |
| 50 | ], | |
| 51 | [availableEnvironments, autoEnvironmentLabel, onAutoEnvironment], | |
| 43 | 52 | ); |
| 44 | 53 | |
| 45 | 54 | // 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 |
| 75 | 84 | return ( |
| 76 | 85 | <Select |
| 77 | 86 | modal={false} |
| 78 | value={environmentId} | |
| 79 | onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} | |
| 87 | value={autoEnvironmentLabel ? "auto" : environmentId} | |
| 88 | onValueChange={(value) => | |
| 89 | value === "auto" ? onAutoEnvironment?.() : onEnvironmentChange(value as EnvironmentId) | |
| 90 | } | |
| 80 | 91 | items={environmentItems} |
| 81 | 92 | > |
| 82 | 93 | <SelectTrigger |
| ... | ... | @@ -86,10 +97,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 86 | 97 | aria-label="Run on" |
| 87 | 98 | data-composer-context-control |
| 88 | 99 | > |
| 89 | <EnvironmentMachineIcon | |
| 90 | kind={activeEnvironment?.machine ?? "server"} | |
| 91 | className="size-3 shrink-0" | |
| 92 | /> | |
| 100 | {autoEnvironmentLabel ? ( | |
| 101 | <ScaleIcon className="size-3 shrink-0" aria-hidden="true" /> | |
| 102 | ) : ( | |
| 103 | <EnvironmentMachineIcon | |
| 104 | kind={activeEnvironment?.machine ?? "server"} | |
| 105 | className="size-3 shrink-0" | |
| 106 | /> | |
| 107 | )} | |
| 93 | 108 | <span |
| 94 | 109 | data-composer-label |
| 95 | 110 | className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0" |
| ... | ... | @@ -105,6 +120,19 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 105 | 120 | <SelectPopup alignItemWithTrigger={false} {...composerFloatingLayerProps}> |
| 106 | 121 | <SelectGroup> |
| 107 | 122 | <SelectGroupLabel>Run on</SelectGroupLabel> |
| 123 | {onAutoEnvironment && ( | |
| 124 | <SelectItem | |
| 125 | value="auto" | |
| 126 | onClick={() => { | |
| 127 | if (autoEnvironmentLabel) onAutoEnvironment?.(); | |
| 128 | }} | |
| 129 | > | |
| 130 | <span className="inline-flex items-center gap-1.5"> | |
| 131 | <ScaleIcon className="size-3" aria-hidden="true" /> | |
| 132 | {autoEnvironmentLabel ?? "Auto balance"} | |
| 133 | </span> | |
| 134 | </SelectItem> | |
| 135 | )} | |
| 108 | 136 | {availableEnvironments.map((env) => ( |
| 109 | 137 | <SelectItem key={env.environmentId} value={env.environmentId}> |
| 110 | 138 | <span className="inline-flex items-center gap-1.5"> |
apps/web/src/components/ChatView.tsx+131| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; | |
| 1 | 2 | import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; |
| 2 | 3 | import { |
| 3 | 4 | collectProviderUsageLimits, |
| ... | ... | @@ -3278,6 +3279,112 @@ export default function ChatView(props: ChatViewProps) { |
| 3278 | 3279 | (activeThread.session !== null && activeThread.session.status !== "stopped")), |
| 3279 | 3280 | ); |
| 3280 | 3281 | |
| 3282 | const loadBalancingSettings = useClientSettings(); | |
| 3283 | const automaticEnvironment = Boolean( | |
| 3284 | clientSettingsHydrated && | |
| 3285 | draftId && | |
| 3286 | !envLocked && | |
| 3287 | hasMultipleEnvironments && | |
| 3288 | loadBalancingSettings.loadBalancingEnabled && | |
| 3289 | draftThread?.environmentSelection !== "manual" && | |
| 3290 | (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) && | |
| 3291 | (!draftThread?.branch || draftThread.environmentSelection === "auto") && | |
| 3292 | !draftThread?.worktreePath, | |
| 3293 | ); | |
| 3294 | const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; | |
| 3295 | const loadBalancingCandidates = useMemo( | |
| 3296 | () => | |
| 3297 | needsLoadBalancing | |
| 3298 | ? logicalProjectEnvironments | |
| 3299 | .filter((candidate) => { | |
| 3300 | const environment = environmentById.get(candidate.environmentId); | |
| 3301 | return ( | |
| 3302 | environment?.connection.phase === "connected" && | |
| 3303 | (loadBalancingSettings.loadBalancingWeights[candidate.environmentId] ?? 50) > 0 && | |
| 3304 | environment.serverConfig?.providers.some( | |
| 3305 | (provider) => | |
| 3306 | (activeProviderInstanceId === null || | |
| 3307 | provider.instanceId === activeProviderInstanceId) && | |
| 3308 | provider.driver === selectedProvider && | |
| 3309 | provider.enabled && | |
| 3310 | provider.installed && | |
| 3311 | provider.status !== "error" && | |
| 3312 | provider.auth.status !== "unauthenticated" && | |
| 3313 | provider.availability !== "unavailable", | |
| 3314 | ) | |
| 3315 | ); | |
| 3316 | }) | |
| 3317 | .map((candidate) => candidate.environmentId) | |
| 3318 | : [], | |
| 3319 | [ | |
| 3320 | needsLoadBalancing, | |
| 3321 | logicalProjectEnvironments, | |
| 3322 | environmentById, | |
| 3323 | loadBalancingSettings.loadBalancingWeights, | |
| 3324 | activeProviderInstanceId, | |
| 3325 | selectedProvider, | |
| 3326 | ], | |
| 3327 | ); | |
| 3328 | const loadBalancing = useLoadBalancedEnvironment( | |
| 3329 | loadBalancingCandidates, | |
| 3330 | loadBalancingSettings.loadBalancingWeights, | |
| 3331 | ); | |
| 3332 | useEffect(() => { | |
| 3333 | if (!needsLoadBalancing || loadBalancing.pending || !draftId || sendInFlightRef.current) return; | |
| 3334 | const target = logicalProjectEnvironments.find( | |
| 3335 | (environment) => environment.environmentId === loadBalancing.environmentId, | |
| 3336 | ); | |
| 3337 | if (!target) return; | |
| 3338 | setDraftThreadContext(draftId, { | |
| 3339 | projectRef: scopeProjectRef(target.environmentId, target.projectId), | |
| 3340 | environmentSelection: "auto", | |
| 3341 | loadBalancedEnvironmentId: target.environmentId, | |
| 3342 | }); | |
| 3343 | }, [ | |
| 3344 | needsLoadBalancing, | |
| 3345 | loadBalancing.pending, | |
| 3346 | loadBalancing.environmentId, | |
| 3347 | draftId, | |
| 3348 | logicalProjectEnvironments, | |
| 3349 | setDraftThreadContext, | |
| 3350 | ]); | |
| 3351 | const onAutoEnvironment = useCallback(() => { | |
| 3352 | if (envLocked || !draftId) return; | |
| 3353 | if (composerHasAttachments) { | |
| 3354 | toastManager.add({ | |
| 3355 | type: "warning", | |
| 3356 | id: "load-balancing-attachments", | |
| 3357 | title: "Keep attachments on this machine", | |
| 3358 | description: | |
| 3359 | "Remove attachments before choosing automatic routing, then attach them on the selected machine.", | |
| 3360 | }); | |
| 3361 | return; | |
| 3362 | } | |
| 3363 | loadBalancing.refresh( | |
| 3364 | logicalProjectEnvironments.map((environment) => environment.environmentId), | |
| 3365 | ); | |
| 3366 | setDraftThreadContext(draftId, { | |
| 3367 | environmentSelection: "auto", | |
| 3368 | loadBalancedEnvironmentId: null, | |
| 3369 | branch: null, | |
| 3370 | worktreePath: null, | |
| 3371 | }); | |
| 3372 | }, [ | |
| 3373 | envLocked, | |
| 3374 | draftId, | |
| 3375 | setDraftThreadContext, | |
| 3376 | loadBalancing.refresh, | |
| 3377 | logicalProjectEnvironments, | |
| 3378 | composerHasAttachments, | |
| 3379 | ]); | |
| 3380 | const autoEnvironmentLabel = automaticEnvironment | |
| 3381 | ? draftThread?.loadBalancedEnvironmentId | |
| 3382 | ? "Auto balance" | |
| 3383 | : loadBalancing.pending | |
| 3384 | ? "Checking machines…" | |
| 3385 | : "Auto balance unavailable" | |
| 3386 | : undefined; | |
| 3387 | ||
| 3281 | 3388 | // Handle environment change for draft threads. When the user picks a |
| 3282 | 3389 | // different environment we update the draft context to point at the physical |
| 3283 | 3390 | // project in that environment while keeping the same logical project. |
| ... | ... | @@ -3290,6 +3397,8 @@ export default function ChatView(props: ChatViewProps) { |
| 3290 | 3397 | if (!target) return; |
| 3291 | 3398 | setDraftThreadContext(draftId, { |
| 3292 | 3399 | projectRef: scopeProjectRef(target.environmentId, target.projectId), |
| 3400 | environmentSelection: "manual", | |
| 3401 | loadBalancedEnvironmentId: null, | |
| 3293 | 3402 | }); |
| 3294 | 3403 | }, |
| 3295 | 3404 | [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], |
| ... | ... | @@ -6249,6 +6358,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6249 | 6358 | !activeThread || |
| 6250 | 6359 | isSendBusy || |
| 6251 | 6360 | isConnecting || |
| 6361 | !clientSettingsHydrated || | |
| 6252 | 6362 | threadDetailLoading || |
| 6253 | 6363 | sendInFlightRef.current || |
| 6254 | 6364 | feedbackUploadsInFlightRef.current.has(routeThreadKey) |
| ... | ... | @@ -6256,6 +6366,18 @@ export default function ChatView(props: ChatViewProps) { |
| 6256 | 6366 | notifyDirectAnnotationAttached(); |
| 6257 | 6367 | return; |
| 6258 | 6368 | } |
| 6369 | if (needsLoadBalancing) { | |
| 6370 | toastManager.add({ | |
| 6371 | type: "warning", | |
| 6372 | title: loadBalancing.pending | |
| 6373 | ? "Checking machine resources" | |
| 6374 | : "Choose a machine to continue", | |
| 6375 | description: loadBalancing.pending | |
| 6376 | ? "Resource checks are still running. You can choose a machine in the composer." | |
| 6377 | : "No eligible machine has available resources. Choose a machine in the composer to override.", | |
| 6378 | }); | |
| 6379 | return; | |
| 6380 | } | |
| 6259 | 6381 | if (activeEnvironmentUnavailable) { |
| 6260 | 6382 | const toastSlot = environmentUnavailableSendToastSlotRef.current; |
| 6261 | 6383 | environmentUnavailableSendToastSlotRef.current = |
| ... | ... | @@ -8231,6 +8353,15 @@ export default function ChatView(props: ChatViewProps) { |
| 8231 | 8353 | ? { onCheckoutPullRequestRequest: openPullRequestDialog } |
| 8232 | 8354 | : {})} |
| 8233 | 8355 | {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} |
| 8356 | autoEnvironmentLabel={autoEnvironmentLabel} | |
| 8357 | onAutoEnvironment={ | |
| 8358 | draftId && | |
| 8359 | !envLocked && | |
| 8360 | hasMultipleEnvironments && | |
| 8361 | loadBalancingSettings.loadBalancingEnabled | |
| 8362 | ? onAutoEnvironment | |
| 8363 | : undefined | |
| 8364 | } | |
| 8234 | 8365 | availableEnvironments={logicalProjectEnvironments} |
| 8235 | 8366 | composerControlsHostRef={setRestingComposerControlsHost} |
| 8236 | 8367 | contextStripVisible={showComposerContextStrip} |
apps/web/src/components/GitActionsControl.tsx+6-2| ... | ... | @@ -1042,7 +1042,7 @@ export default function GitActionsControl({ |
| 1042 | 1042 | }, []); |
| 1043 | 1043 | |
| 1044 | 1044 | const persistThreadBranchSync = useCallback( |
| 1045 | (branch: string | null) => { | |
| 1045 | (branch: string | null, manualSelection = false) => { | |
| 1046 | 1046 | if (!activeThreadRef) { |
| 1047 | 1047 | return; |
| 1048 | 1048 | } |
| ... | ... | @@ -1070,6 +1070,10 @@ export default function GitActionsControl({ |
| 1070 | 1070 | setDraftThreadContext(draftId ?? activeThreadRef, { |
| 1071 | 1071 | branch, |
| 1072 | 1072 | worktreePath: activeDraftThread.worktreePath, |
| 1073 | environmentSelection: manualSelection | |
| 1074 | ? "manual" | |
| 1075 | : (activeDraftThread.environmentSelection ?? | |
| 1076 | (activeDraftThread.branch ? "manual" : "auto")), | |
| 1073 | 1077 | }); |
| 1074 | 1078 | }, |
| 1075 | 1079 | [ |
| ... | ... | @@ -1089,7 +1093,7 @@ export default function GitActionsControl({ |
| 1089 | 1093 | return; |
| 1090 | 1094 | } |
| 1091 | 1095 | |
| 1092 | persistThreadBranchSync(branchUpdate.branch); | |
| 1096 | persistThreadBranchSync(branchUpdate.branch, true); | |
| 1093 | 1097 | }, |
| 1094 | 1098 | [persistThreadBranchSync], |
| 1095 | 1099 | ); |
apps/web/src/components/settings/ConnectionsSettings.tsx+2| ... | ... | @@ -60,6 +60,7 @@ import { |
| 60 | 60 | } from "./settingsLayout"; |
| 61 | 61 | import { searchableSetting } from "./settingsSearch"; |
| 62 | 62 | import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; |
| 63 | import { LoadBalancingSettings } from "./LoadBalancingSettings"; | |
| 63 | 64 | import { Input } from "../ui/input"; |
| 64 | 65 | import { CommandShortcut } from "../ui/command"; |
| 65 | 66 | import { |
| ... | ... | @@ -3592,6 +3593,7 @@ export function ConnectionsSettings() { |
| 3592 | 3593 | savedEnvironments={savedEnvironments} |
| 3593 | 3594 | /> |
| 3594 | 3595 | </SettingsSection> |
| 3596 | <LoadBalancingSettings environments={environments} /> | |
| 3595 | 3597 | </SettingsPageContainer> |
| 3596 | 3598 | ); |
| 3597 | 3599 | } |
apps/web/src/components/settings/LoadBalancingSettings.tsx created+94| ... | ... | @@ -0,0 +1,94 @@ |
| 1 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; | |
| 2 | ||
| 3 | import { | |
| 4 | useClientSettings, | |
| 5 | useClientSettingsHydrated, | |
| 6 | useUpdateClientSettings, | |
| 7 | } from "~/hooks/useSettings"; | |
| 8 | import type { EnvironmentPresentation } from "~/state/environments"; | |
| 9 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | |
| 10 | import { Switch } from "../ui/switch"; | |
| 11 | import { SettingsRow, SettingsSection } from "./settingsLayout"; | |
| 12 | import { searchableSetting } from "./settingsSearch"; | |
| 13 | ||
| 14 | const preferences = [ | |
| 15 | { value: 100, label: "Prefer" }, | |
| 16 | { value: 50, label: "Normal" }, | |
| 17 | { value: 25, label: "Less often" }, | |
| 18 | { value: 0, label: "Manual only" }, | |
| 19 | ]; | |
| 20 | ||
| 21 | export function LoadBalancingSettings({ | |
| 22 | environments, | |
| 23 | }: { | |
| 24 | environments: ReadonlyArray<EnvironmentPresentation>; | |
| 25 | }) { | |
| 26 | const settings = useClientSettings(); | |
| 27 | const settingsHydrated = useClientSettingsHydrated(); | |
| 28 | const updateSettings = useUpdateClientSettings(); | |
| 29 | ||
| 30 | return ( | |
| 31 | <SettingsSection | |
| 32 | {...searchableSetting("load-balancing")} | |
| 33 | description="Choose how often each machine is used. Prefer gives a machine more work when it has capacity; Less often gives it less. Manual only excludes it from automatic selection. Preferences are saved for this client." | |
| 34 | > | |
| 35 | <SettingsRow | |
| 36 | title="Automatically balance load" | |
| 37 | description="Automatically choose a connected machine for new threads in shared projects. You can choose a machine in the composer." | |
| 38 | control={ | |
| 39 | <Switch | |
| 40 | aria-label="Automatically balance load" | |
| 41 | checked={settings.loadBalancingEnabled} | |
| 42 | disabled={!settingsHydrated} | |
| 43 | onCheckedChange={(loadBalancingEnabled) => updateSettings({ loadBalancingEnabled })} | |
| 44 | /> | |
| 45 | } | |
| 46 | /> | |
| 47 | {environments.map((environment) => { | |
| 48 | const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50; | |
| 49 | // Keep saved slider weights until the user chooses a different preference. | |
| 50 | const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100; | |
| 51 | ||
| 52 | return ( | |
| 53 | <SettingsRow | |
| 54 | key={environment.environmentId} | |
| 55 | title={environment.label} | |
| 56 | description={connectionStatusText(environment.connection)} | |
| 57 | control={ | |
| 58 | <Select | |
| 59 | items={preferences} | |
| 60 | value={preference} | |
| 61 | disabled={!settingsHydrated || !settings.loadBalancingEnabled} | |
| 62 | onValueChange={(value) => { | |
| 63 | if (value !== null) { | |
| 64 | updateSettings({ | |
| 65 | loadBalancingWeights: { | |
| 66 | ...settings.loadBalancingWeights, | |
| 67 | [environment.environmentId]: value, | |
| 68 | }, | |
| 69 | }); | |
| 70 | } | |
| 71 | }} | |
| 72 | > | |
| 73 | <SelectTrigger | |
| 74 | size="sm" | |
| 75 | className="w-full sm:w-40" | |
| 76 | aria-label={`${environment.label} load preference`} | |
| 77 | > | |
| 78 | <SelectValue /> | |
| 79 | </SelectTrigger> | |
| 80 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 81 | {preferences.map(({ value, label }) => ( | |
| 82 | <SelectItem key={value} value={value}> | |
| 83 | {label} | |
| 84 | </SelectItem> | |
| 85 | ))} | |
| 86 | </SelectPopup> | |
| 87 | </Select> | |
| 88 | } | |
| 89 | /> | |
| 90 | ); | |
| 91 | })} | |
| 92 | </SettingsSection> | |
| 93 | ); | |
| 94 | } |
apps/web/src/components/settings/settingsSearch.ts+8| ... | ... | @@ -514,6 +514,14 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 514 | 514 | to: "/settings/connections", |
| 515 | 515 | searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"], |
| 516 | 516 | }, |
| 517 | { | |
| 518 | id: "load-balancing", | |
| 519 | title: "Load balancing", | |
| 520 | to: "/settings/connections", | |
| 521 | searchTerms: [ | |
| 522 | "automatic machine environment resources cpu memory capacity preference weight shared projects", | |
| 523 | ], | |
| 524 | }, | |
| 517 | 525 | { |
| 518 | 526 | id: "archive", |
| 519 | 527 | title: "Archived threads", |
apps/web/src/composerDraftStore.test.ts+57| ... | ... | @@ -1623,6 +1623,63 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 1623 | 1623 | expect(file && composerFileNeedsReattach(file)).toBe(true); |
| 1624 | 1624 | }); |
| 1625 | 1625 | |
| 1626 | it("rechecks balancing when an empty draft is remapped to another project member", () => { | |
| 1627 | const store = useComposerDraftStore.getState(); | |
| 1628 | store.setProjectDraftThreadId(projectRef, draftId, { | |
| 1629 | threadId, | |
| 1630 | environmentSelection: "auto", | |
| 1631 | loadBalancedEnvironmentId: TEST_ENVIRONMENT_ID, | |
| 1632 | }); | |
| 1633 | store.setProjectDraftThreadId(remoteProjectRef, draftId, { threadId }); | |
| 1634 | expect(store.getDraftThread(draftId)).toMatchObject({ | |
| 1635 | environmentSelection: "auto", | |
| 1636 | loadBalancedEnvironmentId: null, | |
| 1637 | }); | |
| 1638 | store.setDraftThreadContext(draftId, { loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID }); | |
| 1639 | store.setDraftThreadContext(draftId, { projectRef }); | |
| 1640 | expect(store.getDraftThread(draftId)).toMatchObject({ | |
| 1641 | environmentSelection: "auto", | |
| 1642 | loadBalancedEnvironmentId: null, | |
| 1643 | }); | |
| 1644 | }); | |
| 1645 | ||
| 1646 | it("does not opt a legacy branch choice into balancing when runtime mode changes", () => { | |
| 1647 | const store = useComposerDraftStore.getState(); | |
| 1648 | store.setProjectDraftThreadId(projectRef, draftId, { threadId, branch: "feature/pinned" }); | |
| 1649 | store.setDraftThreadContext(draftId, { runtimeMode: "full-access" }); | |
| 1650 | expect(store.getDraftThread(draftId)?.environmentSelection).toBeUndefined(); | |
| 1651 | expect(store.getDraftThread(draftId)?.branch).toBe("feature/pinned"); | |
| 1652 | }); | |
| 1653 | ||
| 1654 | it("pins manual workspace choices and can return to automatic routing without losing the prompt", () => { | |
| 1655 | const store = useComposerDraftStore.getState(); | |
| 1656 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | |
| 1657 | store.setPrompt(draftId, "keep this prompt"); | |
| 1658 | store.setDraftThreadContext(draftId, { | |
| 1659 | projectRef: remoteProjectRef, | |
| 1660 | environmentSelection: "auto", | |
| 1661 | loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID, | |
| 1662 | }); | |
| 1663 | expect(store.getDraftThread(draftId)).toMatchObject({ | |
| 1664 | environmentId: OTHER_TEST_ENVIRONMENT_ID, | |
| 1665 | environmentSelection: "auto", | |
| 1666 | loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID, | |
| 1667 | }); | |
| 1668 | store.setDraftThreadContext(draftId, { branch: "feature/pinned" }); | |
| 1669 | expect(store.getDraftThread(draftId)?.environmentSelection).toBe("manual"); | |
| 1670 | store.setDraftThreadContext(draftId, { | |
| 1671 | branch: null, | |
| 1672 | environmentSelection: "auto", | |
| 1673 | loadBalancedEnvironmentId: null, | |
| 1674 | }); | |
| 1675 | expect(store.getDraftThread(draftId)).toMatchObject({ | |
| 1676 | branch: null, | |
| 1677 | environmentSelection: "auto", | |
| 1678 | loadBalancedEnvironmentId: null, | |
| 1679 | }); | |
| 1680 | expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt"); | |
| 1681 | }); | |
| 1682 | ||
| 1626 | 1683 | it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => { |
| 1627 | 1684 | const store = useComposerDraftStore.getState(); |
| 1628 | 1685 | store.setProjectDraftThreadId(projectRef, draftId, { |
apps/web/src/composerDraftStore.ts+59| ... | ... | @@ -315,6 +315,8 @@ const PersistedDraftThreadState = Schema.Struct({ |
| 315 | 315 | environmentId: Schema.String, |
| 316 | 316 | projectId: ProjectId, |
| 317 | 317 | logicalProjectKey: Schema.optionalKey(Schema.String), |
| 318 | environmentSelection: Schema.optionalKey(Schema.Literals(["auto", "manual"])), | |
| 319 | loadBalancedEnvironmentId: Schema.optionalKey(Schema.NullOr(Schema.String)), | |
| 318 | 320 | createdAt: Schema.String, |
| 319 | 321 | runtimeMode: RuntimeMode, |
| 320 | 322 | interactionMode: ProviderInteractionMode, |
| ... | ... | @@ -427,6 +429,8 @@ export interface DraftSessionState { |
| 427 | 429 | environmentId: EnvironmentId; |
| 428 | 430 | projectId: ProjectId; |
| 429 | 431 | logicalProjectKey: string; |
| 432 | environmentSelection?: "auto" | "manual"; | |
| 433 | loadBalancedEnvironmentId?: EnvironmentId | null; | |
| 430 | 434 | createdAt: string; |
| 431 | 435 | runtimeMode: RuntimeMode; |
| 432 | 436 | interactionMode: ProviderInteractionMode; |
| ... | ... | @@ -503,6 +507,8 @@ interface ComposerDraftStoreState { |
| 503 | 507 | startFromOrigin?: boolean; |
| 504 | 508 | runtimeMode?: RuntimeMode; |
| 505 | 509 | interactionMode?: ProviderInteractionMode; |
| 510 | environmentSelection?: "auto" | "manual"; | |
| 511 | loadBalancedEnvironmentId?: EnvironmentId | null; | |
| 506 | 512 | }, |
| 507 | 513 | ) => void; |
| 508 | 514 | /** Creates or updates the draft session tracked for a concrete project ref. */ |
| ... | ... | @@ -518,6 +524,8 @@ interface ComposerDraftStoreState { |
| 518 | 524 | startFromOrigin?: boolean; |
| 519 | 525 | runtimeMode?: RuntimeMode; |
| 520 | 526 | interactionMode?: ProviderInteractionMode; |
| 527 | environmentSelection?: "auto" | "manual"; | |
| 528 | loadBalancedEnvironmentId?: EnvironmentId | null; | |
| 521 | 529 | }, |
| 522 | 530 | ) => void; |
| 523 | 531 | /** Updates mutable draft-session metadata without touching composer content. */ |
| ... | ... | @@ -532,6 +540,8 @@ interface ComposerDraftStoreState { |
| 532 | 540 | startFromOrigin?: boolean; |
| 533 | 541 | runtimeMode?: RuntimeMode; |
| 534 | 542 | interactionMode?: ProviderInteractionMode; |
| 543 | environmentSelection?: "auto" | "manual"; | |
| 544 | loadBalancedEnvironmentId?: EnvironmentId | null; | |
| 535 | 545 | }, |
| 536 | 546 | ) => void; |
| 537 | 547 | clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; |
| ... | ... | @@ -1534,6 +1544,8 @@ function createDraftThreadState( |
| 1534 | 1544 | startFromOrigin?: boolean; |
| 1535 | 1545 | runtimeMode?: RuntimeMode; |
| 1536 | 1546 | interactionMode?: ProviderInteractionMode; |
| 1547 | environmentSelection?: "auto" | "manual"; | |
| 1548 | loadBalancedEnvironmentId?: EnvironmentId | null; | |
| 1537 | 1549 | }, |
| 1538 | 1550 | ): DraftThreadState { |
| 1539 | 1551 | // A project change (including switching environments within a logical |
| ... | ... | @@ -1560,11 +1572,23 @@ function createDraftThreadState( |
| 1560 | 1572 | options?.startFromOrigin === undefined |
| 1561 | 1573 | ? (existingThread?.startFromOrigin ?? false) |
| 1562 | 1574 | : options.startFromOrigin; |
| 1575 | const environmentSelection = | |
| 1576 | options?.environmentSelection ?? existingThread?.environmentSelection; | |
| 1563 | 1577 | return { |
| 1564 | 1578 | threadId, |
| 1565 | 1579 | environmentId: projectRef.environmentId, |
| 1566 | 1580 | projectId: projectRef.projectId, |
| 1567 | 1581 | logicalProjectKey, |
| 1582 | ...(environmentSelection ? { environmentSelection } : {}), | |
| 1583 | ...(options?.loadBalancedEnvironmentId !== undefined | |
| 1584 | ? { loadBalancedEnvironmentId: options.loadBalancedEnvironmentId } | |
| 1585 | : existingThread?.loadBalancedEnvironmentId !== undefined | |
| 1586 | ? { | |
| 1587 | loadBalancedEnvironmentId: projectChanged | |
| 1588 | ? null | |
| 1589 | : existingThread.loadBalancedEnvironmentId, | |
| 1590 | } | |
| 1591 | : {}), | |
| 1568 | 1592 | createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), |
| 1569 | 1593 | runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, |
| 1570 | 1594 | interactionMode: |
| ... | ... | @@ -1599,6 +1623,8 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea |
| 1599 | 1623 | left.environmentId === right.environmentId && |
| 1600 | 1624 | left.projectId === right.projectId && |
| 1601 | 1625 | left.logicalProjectKey === right.logicalProjectKey && |
| 1626 | left.environmentSelection === right.environmentSelection && | |
| 1627 | left.loadBalancedEnvironmentId === right.loadBalancedEnvironmentId && | |
| 1602 | 1628 | left.createdAt === right.createdAt && |
| 1603 | 1629 | left.runtimeMode === right.runtimeMode && |
| 1604 | 1630 | left.interactionMode === right.interactionMode && |
| ... | ... | @@ -1754,6 +1780,16 @@ function normalizePersistedDraftThreads( |
| 1754 | 1780 | worktreePath: normalizedWorktreePath, |
| 1755 | 1781 | envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), |
| 1756 | 1782 | startFromOrigin, |
| 1783 | ...(candidateDraftThread.environmentSelection === "manual" || | |
| 1784 | candidateDraftThread.environmentSelection === "auto" | |
| 1785 | ? { environmentSelection: candidateDraftThread.environmentSelection } | |
| 1786 | : {}), | |
| 1787 | ...(typeof candidateDraftThread.loadBalancedEnvironmentId === "string" && | |
| 1788 | candidateDraftThread.loadBalancedEnvironmentId.length > 0 | |
| 1789 | ? { loadBalancedEnvironmentId: candidateDraftThread.loadBalancedEnvironmentId } | |
| 1790 | : candidateDraftThread.loadBalancedEnvironmentId === null | |
| 1791 | ? { loadBalancedEnvironmentId: null } | |
| 1792 | : {}), | |
| 1757 | 1793 | promotedTo, |
| 1758 | 1794 | }; |
| 1759 | 1795 | } |
| ... | ... | @@ -2453,6 +2489,15 @@ function toHydratedDraftThreadState( |
| 2453 | 2489 | worktreePath: persistedDraftThread.worktreePath, |
| 2454 | 2490 | envMode: persistedDraftThread.envMode, |
| 2455 | 2491 | startFromOrigin: persistedDraftThread.startFromOrigin, |
| 2492 | ...(persistedDraftThread.environmentSelection | |
| 2493 | ? { environmentSelection: persistedDraftThread.environmentSelection } | |
| 2494 | : {}), | |
| 2495 | ...(persistedDraftThread.loadBalancedEnvironmentId !== undefined | |
| 2496 | ? { | |
| 2497 | loadBalancedEnvironmentId: | |
| 2498 | persistedDraftThread.loadBalancedEnvironmentId as EnvironmentId | null, | |
| 2499 | } | |
| 2500 | : {}), | |
| 2456 | 2501 | promotedTo: persistedDraftThread.promotedTo |
| 2457 | 2502 | ? scopeThreadRef( |
| 2458 | 2503 | persistedDraftThread.promotedTo.environmentId as EnvironmentId, |
| ... | ... | @@ -2707,11 +2752,23 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2707 | 2752 | options.startFromOrigin === undefined |
| 2708 | 2753 | ? existing.startFromOrigin |
| 2709 | 2754 | : options.startFromOrigin; |
| 2755 | const environmentSelection = | |
| 2756 | options.environmentSelection ?? | |
| 2757 | (options.branch != null || options.worktreePath != null | |
| 2758 | ? "manual" | |
| 2759 | : existing.environmentSelection); | |
| 2710 | 2760 | const nextDraftThread: DraftThreadState = { |
| 2711 | 2761 | threadId: existing.threadId, |
| 2712 | 2762 | environmentId: nextProjectRef.environmentId, |
| 2713 | 2763 | projectId: nextProjectRef.projectId, |
| 2714 | 2764 | logicalProjectKey: existing.logicalProjectKey, |
| 2765 | ...(environmentSelection ? { environmentSelection } : {}), | |
| 2766 | loadBalancedEnvironmentId: | |
| 2767 | options.loadBalancedEnvironmentId === undefined | |
| 2768 | ? projectChanged | |
| 2769 | ? null | |
| 2770 | : (existing.loadBalancedEnvironmentId ?? null) | |
| 2771 | : options.loadBalancedEnvironmentId, | |
| 2715 | 2772 | createdAt: |
| 2716 | 2773 | options.createdAt === undefined |
| 2717 | 2774 | ? existing.createdAt |
| ... | ... | @@ -2729,6 +2786,8 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2729 | 2786 | nextDraftThread.environmentId === existing.environmentId && |
| 2730 | 2787 | nextDraftThread.projectId === existing.projectId && |
| 2731 | 2788 | nextDraftThread.logicalProjectKey === existing.logicalProjectKey && |
| 2789 | nextDraftThread.environmentSelection === existing.environmentSelection && | |
| 2790 | nextDraftThread.loadBalancedEnvironmentId === existing.loadBalancedEnvironmentId && | |
| 2732 | 2791 | nextDraftThread.createdAt === existing.createdAt && |
| 2733 | 2792 | nextDraftThread.runtimeMode === existing.runtimeMode && |
| 2734 | 2793 | nextDraftThread.interactionMode === existing.interactionMode && |
apps/web/src/hooks/useLoadBalancedEnvironment.ts created+50| ... | ... | @@ -0,0 +1,50 @@ |
| 1 | import { RegistryContext, useAtomValue } from "@effect/atom-react"; | |
| 2 | import { chooseLoadBalancedEnvironment } from "@t3tools/client-runtime/load-balancing"; | |
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 4 | import { Atom } from "effect/unstable/reactivity"; | |
| 5 | import { useCallback, useContext, useMemo } from "react"; | |
| 6 | ||
| 7 | import { serverEnvironment } from "../state/server"; | |
| 8 | ||
| 9 | /** Only mounted for unresolved automatic drafts, so idle clients do not poll hosts. */ | |
| 10 | export function useLoadBalancedEnvironment( | |
| 11 | environmentIds: readonly EnvironmentId[], | |
| 12 | weights: Readonly<Record<string, number>>, | |
| 13 | ) { | |
| 14 | const registry = useContext(RegistryContext); | |
| 15 | const refresh = useCallback( | |
| 16 | (ids: readonly EnvironmentId[]) => { | |
| 17 | for (const environmentId of ids) { | |
| 18 | registry.refresh(serverEnvironment.hostResources({ environmentId, input: {} })); | |
| 19 | } | |
| 20 | }, | |
| 21 | [registry], | |
| 22 | ); | |
| 23 | const resourcesAtom = useMemo( | |
| 24 | () => | |
| 25 | Atom.make((get) => | |
| 26 | environmentIds.map((environmentId) => { | |
| 27 | const result = get(serverEnvironment.hostResources({ environmentId, input: {} })); | |
| 28 | return { | |
| 29 | environmentId, | |
| 30 | resources: result._tag === "Success" ? result.value : null, | |
| 31 | receivedAt: result._tag === "Success" ? result.timestamp : 0, | |
| 32 | pending: result._tag === "Initial" || result.waiting, | |
| 33 | }; | |
| 34 | }), | |
| 35 | ), | |
| 36 | [environmentIds], | |
| 37 | ); | |
| 38 | const resources = useAtomValue(resourcesAtom); | |
| 39 | return { | |
| 40 | refresh, | |
| 41 | pending: resources.some((resource) => resource.pending), | |
| 42 | environmentId: chooseLoadBalancedEnvironment( | |
| 43 | resources.map((resource) => ({ | |
| 44 | ...resource, | |
| 45 | weight: weights[resource.environmentId] ?? 50, | |
| 46 | })), | |
| 47 | Date.now(), | |
| 48 | ) as EnvironmentId | null, | |
| 49 | }; | |
| 50 | } |
docs/user/remote-access.md+17| ... | ... | @@ -61,6 +61,23 @@ created in Settings can only be copied from the client that created them while |
| 61 | 61 | its Connections page stays open. If you leave or reload that page, create |
| 62 | 62 | another link to share. |
| 63 | 63 | |
| 64 | ### Balance new threads across machines | |
| 65 | ||
| 66 | Auto balance is off by default. On web and desktop, enable it in | |
| 67 | **Settings → Connections → Load balancing** to automatically choose a machine for | |
| 68 | new threads in projects grouped across connected environments. | |
| 69 | Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and | |
| 70 | memory available, **Less often** to reduce its share, or **Manual only** to exclude | |
| 71 | it from automatic selection. These are preferences, not fixed traffic percentages. | |
| 72 | Preferences are saved separately in each client. | |
| 73 | ||
| 74 | The composer checks eligible machines when choosing a draft's environment, then keeps | |
| 75 | that choice stable. Choose **Auto balance** again to check current resources, or choose | |
| 76 | a specific machine to override it. Choosing a branch or worktree also keeps the draft | |
| 77 | on that machine. Existing threads stay where they started. If resource checks are | |
| 78 | unavailable or all eligible machines are full, choose a machine manually to continue. | |
| 79 | Mobile keeps its manual environment selection. | |
| 80 | ||
| 64 | 81 | ### Tailscale HTTPS |
| 65 | 82 | |
| 66 | 83 | Join both devices to the same tailnet. In the desktop app, enable **Tailscale |
packages/client-runtime/package.json+4| ... | ... | @@ -3,6 +3,10 @@ |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | "./load-balancing": { | |
| 7 | "types": "./src/load-balancing.ts", | |
| 8 | "default": "./src/load-balancing.ts" | |
| 9 | }, | |
| 6 | 10 | "./project-favicon-cache": { |
| 7 | 11 | "types": "./src/projectFaviconCache.ts", |
| 8 | 12 | "default": "./src/projectFaviconCache.ts" |
packages/client-runtime/src/load-balancing.ts created+40| ... | ... | @@ -0,0 +1,40 @@ |
| 1 | import type { HostResourcesSnapshot } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | /** Callers supply only connected machines hosting the project and selected provider. */ | |
| 4 | export function chooseLoadBalancedEnvironment( | |
| 5 | candidates: ReadonlyArray<{ | |
| 6 | environmentId: string; | |
| 7 | resources: HostResourcesSnapshot | null; | |
| 8 | /** Client receipt time avoids comparing clocks on different machines. */ | |
| 9 | receivedAt?: number; | |
| 10 | weight: number; | |
| 11 | }>, | |
| 12 | now: number, | |
| 13 | ): string | null { | |
| 14 | let selected: string | null = null; | |
| 15 | let bestScore = 0; | |
| 16 | for (const { environmentId, resources, receivedAt, weight } of candidates) { | |
| 17 | const sampledAt = receivedAt ?? resources?.sampledAt ?? 0; | |
| 18 | if ( | |
| 19 | !resources || | |
| 20 | !Number.isFinite(weight) || | |
| 21 | weight <= 0 || | |
| 22 | now - sampledAt > 15_000 || | |
| 23 | sampledAt > now + 5_000 || | |
| 24 | resources.cpuUtilization === null || | |
| 25 | resources.cpuUtilization >= 0.95 || | |
| 26 | resources.totalMemoryBytes <= 0 || | |
| 27 | resources.cpuCount <= 0 | |
| 28 | ) { | |
| 29 | continue; | |
| 30 | } | |
| 31 | const memoryAvailable = resources.availableMemoryBytes / resources.totalMemoryBytes; | |
| 32 | if (memoryAvailable <= 0.05) continue; | |
| 33 | const score = weight * resources.cpuCount * (1 - resources.cpuUtilization) * memoryAvailable; | |
| 34 | if (score > bestScore) { | |
| 35 | selected = environmentId; | |
| 36 | bestScore = score; | |
| 37 | } | |
| 38 | } | |
| 39 | return selected; | |
| 40 | } |
packages/client-runtime/src/state/projectGrouping.test.ts+65| ... | ... | @@ -2,6 +2,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | 4 | import type { EnvironmentProject } from "./models.ts"; |
| 5 | import { chooseLoadBalancedEnvironment } from "../load-balancing.ts"; | |
| 5 | 6 | import { |
| 6 | 7 | buildProjectGroups, |
| 7 | 8 | derivePhysicalProjectKey, |
| ... | ... | @@ -9,6 +10,70 @@ import { |
| 9 | 10 | } from "./projectGrouping.ts"; |
| 10 | 11 | |
| 11 | 12 | const environmentId = EnvironmentId.make("environment"); |
| 13 | ||
| 14 | describe("load balancing shared project machines", () => { | |
| 15 | const now = 100_000; | |
| 16 | const resources = { | |
| 17 | sampledAt: now, | |
| 18 | cpuUtilization: 0.2, | |
| 19 | cpuCount: 8, | |
| 20 | availableMemoryBytes: 8_000, | |
| 21 | totalMemoryBytes: 16_000, | |
| 22 | }; | |
| 23 | ||
| 24 | it("compares three machines using free capacity and preference", () => { | |
| 25 | const candidates = [ | |
| 26 | { environmentId: "busy", resources: { ...resources, cpuUtilization: 0.9 }, weight: 1 }, | |
| 27 | { environmentId: "idle", resources, weight: 1 }, | |
| 28 | { environmentId: "preferred", resources: { ...resources, cpuCount: 4 }, weight: 3 }, | |
| 29 | ]; | |
| 30 | expect(chooseLoadBalancedEnvironment(candidates, now)).toBe("preferred"); | |
| 31 | expect(chooseLoadBalancedEnvironment(candidates.slice(0, 2), now)).toBe("idle"); | |
| 32 | }); | |
| 33 | ||
| 34 | it("rejects stale, unknown, excluded and saturated machines", () => { | |
| 35 | expect( | |
| 36 | chooseLoadBalancedEnvironment( | |
| 37 | [ | |
| 38 | { | |
| 39 | environmentId: "stale", | |
| 40 | resources: { ...resources, sampledAt: now - 15_001 }, | |
| 41 | weight: 1, | |
| 42 | }, | |
| 43 | { environmentId: "unknown", resources: null, weight: 1 }, | |
| 44 | { | |
| 45 | environmentId: "no-cpu-sample", | |
| 46 | resources: { ...resources, cpuUtilization: null }, | |
| 47 | weight: 1, | |
| 48 | }, | |
| 49 | { environmentId: "excluded", resources, weight: 0 }, | |
| 50 | { | |
| 51 | environmentId: "cpu-full", | |
| 52 | resources: { ...resources, cpuUtilization: 0.95 }, | |
| 53 | weight: 1, | |
| 54 | }, | |
| 55 | { | |
| 56 | environmentId: "memory-full", | |
| 57 | resources: { ...resources, availableMemoryBytes: 100 }, | |
| 58 | weight: 1, | |
| 59 | }, | |
| 60 | ], | |
| 61 | now, | |
| 62 | ), | |
| 63 | ).toBeNull(); | |
| 64 | }); | |
| 65 | ||
| 66 | it("uses client receipt time when host clocks differ", () => { | |
| 67 | const candidate = { | |
| 68 | environmentId: "different-clock", | |
| 69 | resources: { ...resources, sampledAt: now + 60_000 }, | |
| 70 | receivedAt: now, | |
| 71 | weight: 1, | |
| 72 | }; | |
| 73 | expect(chooseLoadBalancedEnvironment([candidate], now)).toBe("different-clock"); | |
| 74 | expect(chooseLoadBalancedEnvironment([candidate], now + 15_001)).toBeNull(); | |
| 75 | }); | |
| 76 | }); | |
| 12 | 77 | const repositoryIdentity = { |
| 13 | 78 | canonicalKey: "github.com/t3tools/t3code", |
| 14 | 79 | locator: { |
packages/client-runtime/src/state/server.ts+7| ... | ... | @@ -26,6 +26,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 26 | 26 | import { |
| 27 | 27 | createAtomCommandScheduler, |
| 28 | 28 | createEnvironmentRpcCommand, |
| 29 | createEnvironmentQueryAtomFamily, | |
| 29 | 30 | createEnvironmentRpcQueryAtomFamily, |
| 30 | 31 | createEnvironmentRpcSubscriptionAtomFamily, |
| 31 | 32 | createRuntimeCommand, |
| ... | ... | @@ -1018,6 +1019,12 @@ export function createServerEnvironmentAtoms<R, E>( |
| 1018 | 1019 | label: "environment-data:server:process-diagnostics", |
| 1019 | 1020 | tag: WS_METHODS.serverGetProcessDiagnostics, |
| 1020 | 1021 | }), |
| 1022 | hostResources: createEnvironmentQueryAtomFamily(runtime, { | |
| 1023 | label: "environment-data:server:host-resources", | |
| 1024 | staleTimeMs: 5_000, | |
| 1025 | execute: (input: EnvironmentRpcInput<typeof WS_METHODS.serverGetHostResources>) => | |
| 1026 | request(WS_METHODS.serverGetHostResources, input).pipe(Effect.timeout("5 seconds")), | |
| 1027 | }), | |
| 1021 | 1028 | processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 1022 | 1029 | label: "environment-data:server:process-resource-history", |
| 1023 | 1030 | tag: WS_METHODS.serverGetProcessResourceHistory, |
packages/contracts/src/resourceTelemetry.ts+10| ... | ... | @@ -6,6 +6,16 @@ import { DesktopUpdateStateSchema } from "./ipc.ts"; |
| 6 | 6 | |
| 7 | 7 | export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const; |
| 8 | 8 | |
| 9 | /** Whole-host capacity, independent of T3's process diagnostics. */ | |
| 10 | export const HostResourcesSnapshot = Schema.Struct({ | |
| 11 | sampledAt: NonNegativeInt, | |
| 12 | cpuUtilization: Schema.NullOr(Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))), | |
| 13 | cpuCount: NonNegativeInt, | |
| 14 | availableMemoryBytes: NonNegativeInt, | |
| 15 | totalMemoryBytes: NonNegativeInt, | |
| 16 | }); | |
| 17 | export type HostResourcesSnapshot = typeof HostResourcesSnapshot.Type; | |
| 18 | ||
| 9 | 19 | export const ResourceTelemetryIoSemantics = Schema.Literals([ |
| 10 | 20 | "storage", |
| 11 | 21 | "logical", |
packages/contracts/src/rpc.ts+9| ... | ... | @@ -210,6 +210,7 @@ import { |
| 210 | 210 | ServerUpsertKeybindingResult, |
| 211 | 211 | } from "./server.ts"; |
| 212 | 212 | import { |
| 213 | HostResourcesSnapshot, | |
| 213 | 214 | ResourceTelemetryHistory, |
| 214 | 215 | ResourceTelemetryHistoryInput, |
| 215 | 216 | ResourceTelemetryRetryResult, |
| ... | ... | @@ -323,6 +324,7 @@ export const WS_METHODS = { |
| 323 | 324 | serverDiscoverSourceControl: "server.discoverSourceControl", |
| 324 | 325 | serverGetTraceDiagnostics: "server.getTraceDiagnostics", |
| 325 | 326 | serverGetProcessDiagnostics: "server.getProcessDiagnostics", |
| 327 | serverGetHostResources: "server.getHostResources", | |
| 326 | 328 | serverGetProcessResourceHistory: "server.getProcessResourceHistory", |
| 327 | 329 | serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", |
| 328 | 330 | serverRetryResourceTelemetry: "server.retryResourceTelemetry", |
| ... | ... | @@ -538,6 +540,12 @@ const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDia |
| 538 | 540 | error: EnvironmentAuthorizationError, |
| 539 | 541 | }); |
| 540 | 542 | |
| 543 | const WsServerGetHostResourcesRpc = Rpc.make(WS_METHODS.serverGetHostResources, { | |
| 544 | payload: Schema.Struct({}), | |
| 545 | success: HostResourcesSnapshot, | |
| 546 | error: EnvironmentAuthorizationError, | |
| 547 | }); | |
| 548 | ||
| 541 | 549 | const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, { |
| 542 | 550 | payload: ServerProcessResourceHistoryInput, |
| 543 | 551 | success: ServerProcessResourceHistoryResult, |
| ... | ... | @@ -1197,6 +1205,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1197 | 1205 | WsServerDiscoverSourceControlRpc, |
| 1198 | 1206 | WsServerGetTraceDiagnosticsRpc, |
| 1199 | 1207 | WsServerGetProcessDiagnosticsRpc, |
| 1208 | WsServerGetHostResourcesRpc, | |
| 1200 | 1209 | WsServerGetProcessResourceHistoryRpc, |
| 1201 | 1210 | WsServerGetResourceTelemetryHistoryRpc, |
| 1202 | 1211 | WsServerRetryResourceTelemetryRpc, |
packages/contracts/src/settings.test.ts+15| ... | ... | @@ -135,6 +135,21 @@ describe("ClaudeSettings auto-compaction", () => { |
| 135 | 135 | }); |
| 136 | 136 | }); |
| 137 | 137 | |
| 138 | describe("ClientSettings load balancing", () => { | |
| 139 | it("requires opt-in when settings are new or omit load balancing", () => { | |
| 140 | expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false); | |
| 141 | expect(decodeClientSettings({ loadBalancingWeights: {} }).loadBalancingEnabled).toBe(false); | |
| 142 | }); | |
| 143 | ||
| 144 | it.each([true, false])("preserves a saved choice of %s", (loadBalancingEnabled) => { | |
| 145 | const settings = decodeClientSettings({ loadBalancingEnabled }); | |
| 146 | expect(encodeClientSettings(settings).loadBalancingEnabled).toBe(loadBalancingEnabled); | |
| 147 | expect(decodeClientSettingsPatch({ loadBalancingEnabled }).loadBalancingEnabled).toBe( | |
| 148 | loadBalancingEnabled, | |
| 149 | ); | |
| 150 | }); | |
| 151 | }); | |
| 152 | ||
| 138 | 153 | describe("ClientSettings word wrap", () => { |
| 139 | 154 | it("defaults word wrap on", () => { |
| 140 | 155 | expect(decodeClientSettings({}).wordWrap).toBe(true); |
packages/contracts/src/settings.ts+9| ... | ... | @@ -204,7 +204,14 @@ export const BrowserLinkTarget = Schema.Literals(["system", "app"]); |
| 204 | 204 | export type BrowserLinkTarget = typeof BrowserLinkTarget.Type; |
| 205 | 205 | export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system"; |
| 206 | 206 | |
| 207 | export const LoadBalancingWeights = Schema.Record( | |
| 208 | TrimmedNonEmptyString, | |
| 209 | Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })), | |
| 210 | ); | |
| 211 | ||
| 207 | 212 | export const ClientSettingsSchema = Schema.Struct({ |
| 213 | loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | |
| 214 | loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 208 | 215 | appearanceContrast: AppearanceContrast.pipe( |
| 209 | 216 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)), |
| 210 | 217 | ), |
| ... | ... | @@ -1210,6 +1217,8 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1210 | 1217 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
| 1211 | 1218 | |
| 1212 | 1219 | export const ClientSettingsPatch = Schema.Struct({ |
| 1220 | loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), | |
| 1221 | loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), | |
| 1213 | 1222 | appearanceContrast: Schema.optionalKey(AppearanceContrast), |
| 1214 | 1223 | panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs), |
| 1215 | 1224 | browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting), |