| 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,6 +58,8 @@ const clientSettings: ClientSettings = { |
| 58 | sidebarThreadSortOrder: "created_at", | 58 | sidebarThreadSortOrder: "created_at", |
| 59 | sidebarThreadPreviewCount: 6, | 59 | sidebarThreadPreviewCount: 6, |
| 60 | legacySidebarEnabled: false, | 60 | legacySidebarEnabled: false, |
| 61 | loadBalancingEnabled: false, | ||
| 62 | loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, | ||
| 61 | timestampFormat: "24-hour", | 63 | timestampFormat: "24-hour", |
| 62 | wordWrap: true, | 64 | wordWrap: true, |
| 63 | }; | 65 | }; |
apps/server/src/auth/RpcAuthorization.ts+1| ... | @@ -53,6 +53,7 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -53,6 +53,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 53 | [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, | 53 | [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, |
| 54 | [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, | 54 | [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, |
| 55 | [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, | 55 | [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, |
| 56 | [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, | ||
| 56 | [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, | 57 | [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, |
| 57 | [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, | 58 | [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, |
| 58 | [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, | 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,6 +165,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; |
| 165 | import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; | 165 | import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; |
| 166 | import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; | 166 | import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; |
| 167 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 167 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 168 | import * as HostResources from "./resourceTelemetry/HostResources.ts"; | ||
| 168 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 169 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 169 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 170 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 170 | import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; | 171 | import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; |
| ... | @@ -845,7 +846,8 @@ const buildAppUnderTest = (options?: { | ... | @@ -845,7 +846,8 @@ const buildAppUnderTest = (options?: { |
| 845 | }), | 846 | }), |
| 846 | }), | 847 | }), |
| 847 | ), | 848 | ), |
| 848 | Layer.provide( | 849 | Layer.provide([ |
| 850 | HostResources.layer, | ||
| 849 | Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({ | 851 | Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({ |
| 850 | readHistory: (input) => | 852 | readHistory: (input) => |
| 851 | Effect.succeed({ | 853 | Effect.succeed({ |
| ... | @@ -860,7 +862,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -860,7 +862,7 @@ const buildAppUnderTest = (options?: { |
| 860 | error: Option.none(), | 862 | error: Option.none(), |
| 861 | }), | 863 | }), |
| 862 | }), | 864 | }), |
| 863 | ), | 865 | ]), |
| 864 | Layer.provide( | 866 | Layer.provide( |
| 865 | Layer.mock(TraceDiagnostics.TraceDiagnostics)({ | 867 | Layer.mock(TraceDiagnostics.TraceDiagnostics)({ |
| 866 | read: () => | 868 | read: () => |
| ... | @@ -6154,6 +6156,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -6154,6 +6156,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 6154 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | it.effect("routes websocket resource telemetry through the subscription", () => | 6247 | it.effect("routes websocket resource telemetry through the subscription", () => |
| 6158 | Effect.gen(function* () { | 6248 | Effect.gen(function* () { |
| 6159 | yield* buildAppUnderTest(); | 6249 | yield* buildAppUnderTest(); |
apps/server/src/server.ts+2| ... | @@ -115,6 +115,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; | ... | @@ -115,6 +115,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; |
| 115 | import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts"; | 115 | import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts"; |
| 116 | import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; | 116 | import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; |
| 117 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 117 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 118 | import * as HostResources from "./resourceTelemetry/HostResources.ts"; | ||
| 118 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 119 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 119 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 120 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 120 | import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; | 121 | import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; |
| ... | @@ -199,6 +200,7 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( | ... | @@ -199,6 +200,7 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( |
| 199 | const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); | 200 | const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); |
| 200 | 201 | ||
| 201 | const ResourceDiagnosticsLayerLive = Layer.mergeAll( | 202 | const ResourceDiagnosticsLayerLive = Layer.mergeAll( |
| 203 | HostResources.layer, | ||
| 202 | ResourceTelemetryLayerLive, | 204 | ResourceTelemetryLayerLive, |
| 203 | ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), | 205 | ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), |
| 204 | ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), | 206 | ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), |
apps/server/src/ws.ts+6| ... | @@ -135,6 +135,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; | ... | @@ -135,6 +135,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; |
| 135 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 135 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 136 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 136 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 137 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; | 137 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; |
| 138 | import * as HostResources from "./resourceTelemetry/HostResources.ts"; | ||
| 138 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | 139 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; |
| 139 | import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; | 140 | import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; |
| 140 | import * as UsageService from "./usage/UsageService.ts"; | 141 | import * as UsageService from "./usage/UsageService.ts"; |
| ... | @@ -605,6 +606,7 @@ const makeWsRpcLayer = ( | ... | @@ -605,6 +606,7 @@ const makeWsRpcLayer = ( |
| 605 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | 606 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| 606 | const sessions = yield* SessionStore.SessionStore; | 607 | const sessions = yield* SessionStore.SessionStore; |
| 607 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; | 608 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; |
| 609 | const hostResources = yield* HostResources.HostResources; | ||
| 608 | const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; | 610 | const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; |
| 609 | const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; | 611 | const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; |
| 610 | const usage = yield* UsageService.UsageService; | 612 | const usage = yield* UsageService.UsageService; |
| ... | @@ -2020,6 +2022,10 @@ const makeWsRpcLayer = ( | ... | @@ -2020,6 +2022,10 @@ const makeWsRpcLayer = ( |
| 2020 | observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, { | 2022 | observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, { |
| 2021 | "rpc.aggregate": "server", | 2023 | "rpc.aggregate": "server", |
| 2022 | }), | 2024 | }), |
| 2025 | [WS_METHODS.serverGetHostResources]: (_input) => | ||
| 2026 | observeRpcEffect(WS_METHODS.serverGetHostResources, hostResources.read, { | ||
| 2027 | "rpc.aggregate": "server", | ||
| 2028 | }), | ||
| 2023 | [WS_METHODS.serverGetProcessResourceHistory]: (input) => | 2029 | [WS_METHODS.serverGetProcessResourceHistory]: (input) => |
| 2024 | observeRpcEffect( | 2030 | observeRpcEffect( |
| 2025 | WS_METHODS.serverGetProcessResourceHistory, | 2031 | WS_METHODS.serverGetProcessResourceHistory, |
apps/web/src/components/BranchToolbar.tsx+45-7| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | FolderGitIcon, | 6 | FolderGitIcon, |
| 7 | FolderIcon, | 7 | FolderIcon, |
| 8 | HistoryIcon, | 8 | HistoryIcon, |
| 9 | ScaleIcon, | ||
| 9 | } from "lucide-react"; | 10 | } from "lucide-react"; |
| 10 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 11 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 11 | 12 | ||
| ... | @@ -56,6 +57,8 @@ interface BranchToolbarProps { | ... | @@ -56,6 +57,8 @@ interface BranchToolbarProps { |
| 56 | onActiveThreadBranchOverrideChange?: (branch: string | null) => void; | 57 | onActiveThreadBranchOverrideChange?: (branch: string | null) => void; |
| 57 | startFromOrigin: boolean; | 58 | startFromOrigin: boolean; |
| 58 | onStartFromOriginChange: (startFromOrigin: boolean) => void; | 59 | onStartFromOriginChange: (startFromOrigin: boolean) => void; |
| 60 | autoEnvironmentLabel?: string | undefined; | ||
| 61 | onAutoEnvironment?: (() => void) | undefined; | ||
| 59 | envLocked: boolean; | 62 | envLocked: boolean; |
| 60 | onCheckoutPullRequestRequest?: (reference: string) => void; | 63 | onCheckoutPullRequestRequest?: (reference: string) => void; |
| 61 | onComposerFocusRequest?: () => void; | 64 | onComposerFocusRequest?: () => void; |
| ... | @@ -66,6 +69,8 @@ interface BranchToolbarProps { | ... | @@ -66,6 +69,8 @@ interface BranchToolbarProps { |
| 66 | } | 69 | } |
| 67 | 70 | ||
| 68 | interface MobileRunContextSelectorProps { | 71 | interface MobileRunContextSelectorProps { |
| 72 | autoEnvironmentLabel?: string | undefined; | ||
| 73 | onAutoEnvironment?: (() => void) | undefined; | ||
| 69 | envLocked: boolean; | 74 | envLocked: boolean; |
| 70 | envModeLocked: boolean; | 75 | envModeLocked: boolean; |
| 71 | environmentId: EnvironmentId; | 76 | environmentId: EnvironmentId; |
| ... | @@ -81,6 +86,8 @@ interface MobileRunContextSelectorProps { | ... | @@ -81,6 +86,8 @@ interface MobileRunContextSelectorProps { |
| 81 | } | 86 | } |
| 82 | 87 | ||
| 83 | const MobileRunContextSelector = memo(function MobileRunContextSelector({ | 88 | const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 89 | autoEnvironmentLabel, | ||
| 90 | onAutoEnvironment, | ||
| 84 | envLocked, | 91 | envLocked, |
| 85 | envModeLocked, | 92 | envModeLocked, |
| 86 | environmentId, | 93 | environmentId, |
| ... | @@ -114,10 +121,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -114,10 +121,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 114 | // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px | 121 | // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px |
| 115 | // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. | 122 | // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. |
| 116 | <span className="inline-flex shrink-0 items-center gap-0.5"> | 123 | <span className="inline-flex shrink-0 items-center gap-0.5"> |
| 117 | <EnvironmentMachineIcon | 124 | {autoEnvironmentLabel ? ( |
| 118 | kind={activeEnvironment?.machine ?? "server"} | 125 | <ScaleIcon className="size-3 shrink-0 mx-0!" aria-hidden="true" /> |
| 119 | className="size-3 shrink-0 mx-0!" | 126 | ) : ( |
| 120 | /> | 127 | <EnvironmentMachineIcon |
| 128 | kind={activeEnvironment?.machine ?? "server"} | ||
| 129 | className="size-3 shrink-0 mx-0!" | ||
| 130 | /> | ||
| 131 | )} | ||
| 121 | <WorkspaceIcon className="size-3 shrink-0 mx-0!" /> | 132 | <WorkspaceIcon className="size-3 shrink-0 mx-0!" /> |
| 122 | </span> | 133 | </span> |
| 123 | ) : ( | 134 | ) : ( |
| ... | @@ -134,7 +145,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -134,7 +145,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 134 | data-composer-label-motion | 145 | data-composer-label-motion |
| 135 | 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" | 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 | </span> | 150 | </span> |
| 139 | </span> | 151 | </span> |
| 140 | </> | 152 | </> |
| ... | @@ -167,9 +179,29 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -167,9 +179,29 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 167 | <MenuGroup> | 179 | <MenuGroup> |
| 168 | <MenuGroupLabel>Run on</MenuGroupLabel> | 180 | <MenuGroupLabel>Run on</MenuGroupLabel> |
| 169 | <MenuRadioGroup | 181 | <MenuRadioGroup |
| 170 | value={environmentId} | 182 | value={autoEnvironmentLabel ? "auto" : environmentId} |
| 171 | onValueChange={(value) => onEnvironmentChange(value as 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 | {availableEnvironments.map((env) => ( | 205 | {availableEnvironments.map((env) => ( |
| 174 | <MenuRadioItem | 206 | <MenuRadioItem |
| 175 | key={env.environmentId} | 207 | key={env.environmentId} |
| ... | @@ -421,6 +453,8 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -421,6 +453,8 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 421 | onActiveThreadBranchOverrideChange, | 453 | onActiveThreadBranchOverrideChange, |
| 422 | startFromOrigin, | 454 | startFromOrigin, |
| 423 | onStartFromOriginChange, | 455 | onStartFromOriginChange, |
| 456 | autoEnvironmentLabel, | ||
| 457 | onAutoEnvironment, | ||
| 424 | envLocked, | 458 | envLocked, |
| 425 | onCheckoutPullRequestRequest, | 459 | onCheckoutPullRequestRequest, |
| 426 | onComposerFocusRequest, | 460 | onComposerFocusRequest, |
| ... | @@ -518,6 +552,8 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -518,6 +552,8 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 518 | {showGitControls ? ( | 552 | {showGitControls ? ( |
| 519 | <div className="contents @3xl/composer-surface:hidden"> | 553 | <div className="contents @3xl/composer-surface:hidden"> |
| 520 | <MobileRunContextSelector | 554 | <MobileRunContextSelector |
| 555 | autoEnvironmentLabel={autoEnvironmentLabel} | ||
| 556 | onAutoEnvironment={onAutoEnvironment} | ||
| 521 | envLocked={envLocked} | 557 | envLocked={envLocked} |
| 522 | envModeLocked={envModeLocked} | 558 | envModeLocked={envModeLocked} |
| 523 | environmentId={environmentId} | 559 | environmentId={environmentId} |
| ... | @@ -544,6 +580,8 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -544,6 +580,8 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 544 | {showEnvironmentIndicator && availableEnvironments && ( | 580 | {showEnvironmentIndicator && availableEnvironments && ( |
| 545 | <> | 581 | <> |
| 546 | <BranchToolbarEnvironmentSelector | 582 | <BranchToolbarEnvironmentSelector |
| 583 | autoEnvironmentLabel={autoEnvironmentLabel} | ||
| 584 | onAutoEnvironment={onAutoEnvironment} | ||
| 547 | envLocked={envLocked} | 585 | envLocked={envLocked} |
| 548 | environmentId={environmentId} | 586 | environmentId={environmentId} |
| 549 | availableEnvironments={availableEnvironments} | 587 | availableEnvironments={availableEnvironments} |
apps/web/src/components/BranchToolbarBranchSelector.tsx+4-2| ... | @@ -155,7 +155,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -155,7 +155,7 @@ export function BranchToolbarBranchSelector({ |
| 155 | // Thread branch mutation (colocated — only this component calls it) | 155 | // Thread branch mutation (colocated — only this component calls it) |
| 156 | // --------------------------------------------------------------------------- | 156 | // --------------------------------------------------------------------------- |
| 157 | const setThreadBranch = useCallback( | 157 | const setThreadBranch = useCallback( |
| 158 | (branch: string | null, worktreePath: string | null) => { | 158 | (branch: string | null, worktreePath: string | null, automatic = false) => { |
| 159 | if (!activeThreadId || !activeProject) return; | 159 | if (!activeThreadId || !activeProject) return; |
| 160 | if (serverSession && worktreePath !== activeWorktreePath) { | 160 | if (serverSession && worktreePath !== activeWorktreePath) { |
| 161 | void stopThreadSession({ | 161 | void stopThreadSession({ |
| ... | @@ -186,6 +186,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -186,6 +186,7 @@ export function BranchToolbarBranchSelector({ |
| 186 | branch, | 186 | branch, |
| 187 | worktreePath, | 187 | worktreePath, |
| 188 | envMode: nextDraftEnvMode, | 188 | envMode: nextDraftEnvMode, |
| 189 | environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual", | ||
| 189 | projectRef: scopeProjectRef(environmentId, activeProject.id), | 190 | projectRef: scopeProjectRef(environmentId, activeProject.id), |
| 190 | }); | 191 | }); |
| 191 | }, | 192 | }, |
| ... | @@ -201,6 +202,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -201,6 +202,7 @@ export function BranchToolbarBranchSelector({ |
| 201 | threadRef, | 202 | threadRef, |
| 202 | environmentId, | 203 | environmentId, |
| 203 | effectiveEnvMode, | 204 | effectiveEnvMode, |
| 205 | draftThread?.environmentSelection, | ||
| 204 | stopThreadSession, | 206 | stopThreadSession, |
| 205 | updateThreadMetadata, | 207 | updateThreadMetadata, |
| 206 | ], | 208 | ], |
| ... | @@ -507,7 +509,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -507,7 +509,7 @@ export function BranchToolbarBranchSelector({ |
| 507 | ) { | 509 | ) { |
| 508 | return; | 510 | return; |
| 509 | } | 511 | } |
| 510 | setThreadBranch(worktreeBaseBranchCandidate, null); | 512 | setThreadBranch(worktreeBaseBranchCandidate, null, true); |
| 511 | }, [ | 513 | }, [ |
| 512 | activeThreadBranch, | 514 | activeThreadBranch, |
| 513 | activeWorktreePath, | 515 | activeWorktreePath, |
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+37-9| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { ScaleIcon } from "lucide-react"; | ||
| 2 | import { memo, useMemo } from "react"; | 3 | import { memo, useMemo } from "react"; |
| 3 | 4 | ||
| 4 | import type { EnvironmentOption } from "./BranchToolbar.logic"; | 5 | import type { EnvironmentOption } from "./BranchToolbar.logic"; |
| ... | @@ -15,6 +16,8 @@ import { | ... | @@ -15,6 +16,8 @@ import { |
| 15 | } from "./ui/select"; | 16 | } from "./ui/select"; |
| 16 | 17 | ||
| 17 | interface BranchToolbarEnvironmentSelectorProps { | 18 | interface BranchToolbarEnvironmentSelectorProps { |
| 19 | autoEnvironmentLabel?: string | undefined; | ||
| 20 | onAutoEnvironment?: (() => void) | undefined; | ||
| 18 | envLocked: boolean; | 21 | envLocked: boolean; |
| 19 | environmentId: EnvironmentId; | 22 | environmentId: EnvironmentId; |
| 20 | availableEnvironments: readonly EnvironmentOption[]; | 23 | availableEnvironments: readonly EnvironmentOption[]; |
| ... | @@ -24,6 +27,8 @@ interface BranchToolbarEnvironmentSelectorProps { | ... | @@ -24,6 +27,8 @@ interface BranchToolbarEnvironmentSelectorProps { |
| 24 | } | 27 | } |
| 25 | 28 | ||
| 26 | export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ | 29 | export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ |
| 30 | autoEnvironmentLabel, | ||
| 31 | onAutoEnvironment, | ||
| 27 | envLocked, | 32 | envLocked, |
| 28 | environmentId, | 33 | environmentId, |
| 29 | availableEnvironments, | 34 | availableEnvironments, |
| ... | @@ -34,12 +39,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir | ... | @@ -34,12 +39,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 34 | }, [availableEnvironments, environmentId]); | 39 | }, [availableEnvironments, environmentId]); |
| 35 | 40 | ||
| 36 | const environmentItems = useMemo( | 41 | const environmentItems = useMemo( |
| 37 | () => | 42 | () => [ |
| 38 | availableEnvironments.map((env) => ({ | 43 | ...(onAutoEnvironment |
| 44 | ? [{ value: "auto", label: autoEnvironmentLabel ?? "Auto balance" }] | ||
| 45 | : []), | ||
| 46 | ...availableEnvironments.map((env) => ({ | ||
| 39 | value: env.environmentId, | 47 | value: env.environmentId, |
| 40 | label: env.label, | 48 | label: env.label, |
| 41 | })), | 49 | })), |
| 42 | [availableEnvironments], | 50 | ], |
| 51 | [availableEnvironments, autoEnvironmentLabel, onAutoEnvironment], | ||
| 43 | ); | 52 | ); |
| 44 | 53 | ||
| 45 | // The static label carries the xs control's height (h-7 sm:h-6) as well as | 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,8 +84,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 75 | return ( | 84 | return ( |
| 76 | <Select | 85 | <Select |
| 77 | modal={false} | 86 | modal={false} |
| 78 | value={environmentId} | 87 | value={autoEnvironmentLabel ? "auto" : environmentId} |
| 79 | onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} | 88 | onValueChange={(value) => |
| 89 | value === "auto" ? onAutoEnvironment?.() : onEnvironmentChange(value as EnvironmentId) | ||
| 90 | } | ||
| 80 | items={environmentItems} | 91 | items={environmentItems} |
| 81 | > | 92 | > |
| 82 | <SelectTrigger | 93 | <SelectTrigger |
| ... | @@ -86,10 +97,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir | ... | @@ -86,10 +97,14 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 86 | aria-label="Run on" | 97 | aria-label="Run on" |
| 87 | data-composer-context-control | 98 | data-composer-context-control |
| 88 | > | 99 | > |
| 89 | <EnvironmentMachineIcon | 100 | {autoEnvironmentLabel ? ( |
| 90 | kind={activeEnvironment?.machine ?? "server"} | 101 | <ScaleIcon className="size-3 shrink-0" aria-hidden="true" /> |
| 91 | className="size-3 shrink-0" | 102 | ) : ( |
| 92 | /> | 103 | <EnvironmentMachineIcon |
| 104 | kind={activeEnvironment?.machine ?? "server"} | ||
| 105 | className="size-3 shrink-0" | ||
| 106 | /> | ||
| 107 | )} | ||
| 93 | <span | 108 | <span |
| 94 | data-composer-label | 109 | data-composer-label |
| 95 | className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0" | 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,6 +120,19 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 105 | <SelectPopup alignItemWithTrigger={false} {...composerFloatingLayerProps}> | 120 | <SelectPopup alignItemWithTrigger={false} {...composerFloatingLayerProps}> |
| 106 | <SelectGroup> | 121 | <SelectGroup> |
| 107 | <SelectGroupLabel>Run on</SelectGroupLabel> | 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 | {availableEnvironments.map((env) => ( | 136 | {availableEnvironments.map((env) => ( |
| 109 | <SelectItem key={env.environmentId} value={env.environmentId}> | 137 | <SelectItem key={env.environmentId} value={env.environmentId}> |
| 110 | <span className="inline-flex items-center gap-1.5"> | 138 | <span className="inline-flex items-center gap-1.5"> |
apps/web/src/components/ChatView.tsx+131| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; | ||
| 1 | import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; | 2 | import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; |
| 2 | import { | 3 | import { |
| 3 | collectProviderUsageLimits, | 4 | collectProviderUsageLimits, |
| ... | @@ -3278,6 +3279,112 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3278,6 +3279,112 @@ export default function ChatView(props: ChatViewProps) { |
| 3278 | (activeThread.session !== null && activeThread.session.status !== "stopped")), | 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 | // Handle environment change for draft threads. When the user picks a | 3388 | // Handle environment change for draft threads. When the user picks a |
| 3282 | // different environment we update the draft context to point at the physical | 3389 | // different environment we update the draft context to point at the physical |
| 3283 | // project in that environment while keeping the same logical project. | 3390 | // project in that environment while keeping the same logical project. |
| ... | @@ -3290,6 +3397,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3290,6 +3397,8 @@ export default function ChatView(props: ChatViewProps) { |
| 3290 | if (!target) return; | 3397 | if (!target) return; |
| 3291 | setDraftThreadContext(draftId, { | 3398 | setDraftThreadContext(draftId, { |
| 3292 | projectRef: scopeProjectRef(target.environmentId, target.projectId), | 3399 | projectRef: scopeProjectRef(target.environmentId, target.projectId), |
| 3400 | environmentSelection: "manual", | ||
| 3401 | loadBalancedEnvironmentId: null, | ||
| 3293 | }); | 3402 | }); |
| 3294 | }, | 3403 | }, |
| 3295 | [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], | 3404 | [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], |
| ... | @@ -6249,6 +6358,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -6249,6 +6358,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6249 | !activeThread || | 6358 | !activeThread || |
| 6250 | isSendBusy || | 6359 | isSendBusy || |
| 6251 | isConnecting || | 6360 | isConnecting || |
| 6361 | !clientSettingsHydrated || | ||
| 6252 | threadDetailLoading || | 6362 | threadDetailLoading || |
| 6253 | sendInFlightRef.current || | 6363 | sendInFlightRef.current || |
| 6254 | feedbackUploadsInFlightRef.current.has(routeThreadKey) | 6364 | feedbackUploadsInFlightRef.current.has(routeThreadKey) |
| ... | @@ -6256,6 +6366,18 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -6256,6 +6366,18 @@ export default function ChatView(props: ChatViewProps) { |
| 6256 | notifyDirectAnnotationAttached(); | 6366 | notifyDirectAnnotationAttached(); |
| 6257 | return; | 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 | if (activeEnvironmentUnavailable) { | 6381 | if (activeEnvironmentUnavailable) { |
| 6260 | const toastSlot = environmentUnavailableSendToastSlotRef.current; | 6382 | const toastSlot = environmentUnavailableSendToastSlotRef.current; |
| 6261 | environmentUnavailableSendToastSlotRef.current = | 6383 | environmentUnavailableSendToastSlotRef.current = |
| ... | @@ -8231,6 +8353,15 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8231,6 +8353,15 @@ export default function ChatView(props: ChatViewProps) { |
| 8231 | ? { onCheckoutPullRequestRequest: openPullRequestDialog } | 8353 | ? { onCheckoutPullRequestRequest: openPullRequestDialog } |
| 8232 | : {})} | 8354 | : {})} |
| 8233 | {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} | 8355 | {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} |
| 8356 | autoEnvironmentLabel={autoEnvironmentLabel} | ||
| 8357 | onAutoEnvironment={ | ||
| 8358 | draftId && | ||
| 8359 | !envLocked && | ||
| 8360 | hasMultipleEnvironments && | ||
| 8361 | loadBalancingSettings.loadBalancingEnabled | ||
| 8362 | ? onAutoEnvironment | ||
| 8363 | : undefined | ||
| 8364 | } | ||
| 8234 | availableEnvironments={logicalProjectEnvironments} | 8365 | availableEnvironments={logicalProjectEnvironments} |
| 8235 | composerControlsHostRef={setRestingComposerControlsHost} | 8366 | composerControlsHostRef={setRestingComposerControlsHost} |
| 8236 | contextStripVisible={showComposerContextStrip} | 8367 | contextStripVisible={showComposerContextStrip} |
apps/web/src/components/GitActionsControl.tsx+6-2| ... | @@ -1042,7 +1042,7 @@ export default function GitActionsControl({ | ... | @@ -1042,7 +1042,7 @@ export default function GitActionsControl({ |
| 1042 | }, []); | 1042 | }, []); |
| 1043 | 1043 | ||
| 1044 | const persistThreadBranchSync = useCallback( | 1044 | const persistThreadBranchSync = useCallback( |
| 1045 | (branch: string | null) => { | 1045 | (branch: string | null, manualSelection = false) => { |
| 1046 | if (!activeThreadRef) { | 1046 | if (!activeThreadRef) { |
| 1047 | return; | 1047 | return; |
| 1048 | } | 1048 | } |
| ... | @@ -1070,6 +1070,10 @@ export default function GitActionsControl({ | ... | @@ -1070,6 +1070,10 @@ export default function GitActionsControl({ |
| 1070 | setDraftThreadContext(draftId ?? activeThreadRef, { | 1070 | setDraftThreadContext(draftId ?? activeThreadRef, { |
| 1071 | branch, | 1071 | branch, |
| 1072 | worktreePath: activeDraftThread.worktreePath, | 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,7 +1093,7 @@ export default function GitActionsControl({ |
| 1089 | return; | 1093 | return; |
| 1090 | } | 1094 | } |
| 1091 | 1095 | ||
| 1092 | persistThreadBranchSync(branchUpdate.branch); | 1096 | persistThreadBranchSync(branchUpdate.branch, true); |
| 1093 | }, | 1097 | }, |
| 1094 | [persistThreadBranchSync], | 1098 | [persistThreadBranchSync], |
| 1095 | ); | 1099 | ); |
apps/web/src/components/settings/ConnectionsSettings.tsx+2| ... | @@ -60,6 +60,7 @@ import { | ... | @@ -60,6 +60,7 @@ import { |
| 60 | } from "./settingsLayout"; | 60 | } from "./settingsLayout"; |
| 61 | import { searchableSetting } from "./settingsSearch"; | 61 | import { searchableSetting } from "./settingsSearch"; |
| 62 | import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; | 62 | import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; |
| 63 | import { LoadBalancingSettings } from "./LoadBalancingSettings"; | ||
| 63 | import { Input } from "../ui/input"; | 64 | import { Input } from "../ui/input"; |
| 64 | import { CommandShortcut } from "../ui/command"; | 65 | import { CommandShortcut } from "../ui/command"; |
| 65 | import { | 66 | import { |
| ... | @@ -3592,6 +3593,7 @@ export function ConnectionsSettings() { | ... | @@ -3592,6 +3593,7 @@ export function ConnectionsSettings() { |
| 3592 | savedEnvironments={savedEnvironments} | 3593 | savedEnvironments={savedEnvironments} |
| 3593 | /> | 3594 | /> |
| 3594 | </SettingsSection> | 3595 | </SettingsSection> |
| 3596 | <LoadBalancingSettings environments={environments} /> | ||
| 3595 | </SettingsPageContainer> | 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,6 +514,14 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 514 | to: "/settings/connections", | 514 | to: "/settings/connections", |
| 515 | searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"], | 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 | id: "archive", | 526 | id: "archive", |
| 519 | title: "Archived threads", | 527 | title: "Archived threads", |
apps/web/src/composerDraftStore.test.ts+57| ... | @@ -1623,6 +1623,63 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -1623,6 +1623,63 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 1623 | expect(file && composerFileNeedsReattach(file)).toBe(true); | 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 | it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => { | 1683 | it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => { |
| 1627 | const store = useComposerDraftStore.getState(); | 1684 | const store = useComposerDraftStore.getState(); |
| 1628 | store.setProjectDraftThreadId(projectRef, draftId, { | 1685 | store.setProjectDraftThreadId(projectRef, draftId, { |
apps/web/src/composerDraftStore.ts+59| ... | @@ -315,6 +315,8 @@ const PersistedDraftThreadState = Schema.Struct({ | ... | @@ -315,6 +315,8 @@ const PersistedDraftThreadState = Schema.Struct({ |
| 315 | environmentId: Schema.String, | 315 | environmentId: Schema.String, |
| 316 | projectId: ProjectId, | 316 | projectId: ProjectId, |
| 317 | logicalProjectKey: Schema.optionalKey(Schema.String), | 317 | logicalProjectKey: Schema.optionalKey(Schema.String), |
| 318 | environmentSelection: Schema.optionalKey(Schema.Literals(["auto", "manual"])), | ||
| 319 | loadBalancedEnvironmentId: Schema.optionalKey(Schema.NullOr(Schema.String)), | ||
| 318 | createdAt: Schema.String, | 320 | createdAt: Schema.String, |
| 319 | runtimeMode: RuntimeMode, | 321 | runtimeMode: RuntimeMode, |
| 320 | interactionMode: ProviderInteractionMode, | 322 | interactionMode: ProviderInteractionMode, |
| ... | @@ -427,6 +429,8 @@ export interface DraftSessionState { | ... | @@ -427,6 +429,8 @@ export interface DraftSessionState { |
| 427 | environmentId: EnvironmentId; | 429 | environmentId: EnvironmentId; |
| 428 | projectId: ProjectId; | 430 | projectId: ProjectId; |
| 429 | logicalProjectKey: string; | 431 | logicalProjectKey: string; |
| 432 | environmentSelection?: "auto" | "manual"; | ||
| 433 | loadBalancedEnvironmentId?: EnvironmentId | null; | ||
| 430 | createdAt: string; | 434 | createdAt: string; |
| 431 | runtimeMode: RuntimeMode; | 435 | runtimeMode: RuntimeMode; |
| 432 | interactionMode: ProviderInteractionMode; | 436 | interactionMode: ProviderInteractionMode; |
| ... | @@ -503,6 +507,8 @@ interface ComposerDraftStoreState { | ... | @@ -503,6 +507,8 @@ interface ComposerDraftStoreState { |
| 503 | startFromOrigin?: boolean; | 507 | startFromOrigin?: boolean; |
| 504 | runtimeMode?: RuntimeMode; | 508 | runtimeMode?: RuntimeMode; |
| 505 | interactionMode?: ProviderInteractionMode; | 509 | interactionMode?: ProviderInteractionMode; |
| 510 | environmentSelection?: "auto" | "manual"; | ||
| 511 | loadBalancedEnvironmentId?: EnvironmentId | null; | ||
| 506 | }, | 512 | }, |
| 507 | ) => void; | 513 | ) => void; |
| 508 | /** Creates or updates the draft session tracked for a concrete project ref. */ | 514 | /** Creates or updates the draft session tracked for a concrete project ref. */ |
| ... | @@ -518,6 +524,8 @@ interface ComposerDraftStoreState { | ... | @@ -518,6 +524,8 @@ interface ComposerDraftStoreState { |
| 518 | startFromOrigin?: boolean; | 524 | startFromOrigin?: boolean; |
| 519 | runtimeMode?: RuntimeMode; | 525 | runtimeMode?: RuntimeMode; |
| 520 | interactionMode?: ProviderInteractionMode; | 526 | interactionMode?: ProviderInteractionMode; |
| 527 | environmentSelection?: "auto" | "manual"; | ||
| 528 | loadBalancedEnvironmentId?: EnvironmentId | null; | ||
| 521 | }, | 529 | }, |
| 522 | ) => void; | 530 | ) => void; |
| 523 | /** Updates mutable draft-session metadata without touching composer content. */ | 531 | /** Updates mutable draft-session metadata without touching composer content. */ |
| ... | @@ -532,6 +540,8 @@ interface ComposerDraftStoreState { | ... | @@ -532,6 +540,8 @@ interface ComposerDraftStoreState { |
| 532 | startFromOrigin?: boolean; | 540 | startFromOrigin?: boolean; |
| 533 | runtimeMode?: RuntimeMode; | 541 | runtimeMode?: RuntimeMode; |
| 534 | interactionMode?: ProviderInteractionMode; | 542 | interactionMode?: ProviderInteractionMode; |
| 543 | environmentSelection?: "auto" | "manual"; | ||
| 544 | loadBalancedEnvironmentId?: EnvironmentId | null; | ||
| 535 | }, | 545 | }, |
| 536 | ) => void; | 546 | ) => void; |
| 537 | clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; | 547 | clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; |
| ... | @@ -1534,6 +1544,8 @@ function createDraftThreadState( | ... | @@ -1534,6 +1544,8 @@ function createDraftThreadState( |
| 1534 | startFromOrigin?: boolean; | 1544 | startFromOrigin?: boolean; |
| 1535 | runtimeMode?: RuntimeMode; | 1545 | runtimeMode?: RuntimeMode; |
| 1536 | interactionMode?: ProviderInteractionMode; | 1546 | interactionMode?: ProviderInteractionMode; |
| 1547 | environmentSelection?: "auto" | "manual"; | ||
| 1548 | loadBalancedEnvironmentId?: EnvironmentId | null; | ||
| 1537 | }, | 1549 | }, |
| 1538 | ): DraftThreadState { | 1550 | ): DraftThreadState { |
| 1539 | // A project change (including switching environments within a logical | 1551 | // A project change (including switching environments within a logical |
| ... | @@ -1560,11 +1572,23 @@ function createDraftThreadState( | ... | @@ -1560,11 +1572,23 @@ function createDraftThreadState( |
| 1560 | options?.startFromOrigin === undefined | 1572 | options?.startFromOrigin === undefined |
| 1561 | ? (existingThread?.startFromOrigin ?? false) | 1573 | ? (existingThread?.startFromOrigin ?? false) |
| 1562 | : options.startFromOrigin; | 1574 | : options.startFromOrigin; |
| 1575 | const environmentSelection = | ||
| 1576 | options?.environmentSelection ?? existingThread?.environmentSelection; | ||
| 1563 | return { | 1577 | return { |
| 1564 | threadId, | 1578 | threadId, |
| 1565 | environmentId: projectRef.environmentId, | 1579 | environmentId: projectRef.environmentId, |
| 1566 | projectId: projectRef.projectId, | 1580 | projectId: projectRef.projectId, |
| 1567 | logicalProjectKey, | 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 | createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), | 1592 | createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), |
| 1569 | runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, | 1593 | runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, |
| 1570 | interactionMode: | 1594 | interactionMode: |
| ... | @@ -1599,6 +1623,8 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea | ... | @@ -1599,6 +1623,8 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea |
| 1599 | left.environmentId === right.environmentId && | 1623 | left.environmentId === right.environmentId && |
| 1600 | left.projectId === right.projectId && | 1624 | left.projectId === right.projectId && |
| 1601 | left.logicalProjectKey === right.logicalProjectKey && | 1625 | left.logicalProjectKey === right.logicalProjectKey && |
| 1626 | left.environmentSelection === right.environmentSelection && | ||
| 1627 | left.loadBalancedEnvironmentId === right.loadBalancedEnvironmentId && | ||
| 1602 | left.createdAt === right.createdAt && | 1628 | left.createdAt === right.createdAt && |
| 1603 | left.runtimeMode === right.runtimeMode && | 1629 | left.runtimeMode === right.runtimeMode && |
| 1604 | left.interactionMode === right.interactionMode && | 1630 | left.interactionMode === right.interactionMode && |
| ... | @@ -1754,6 +1780,16 @@ function normalizePersistedDraftThreads( | ... | @@ -1754,6 +1780,16 @@ function normalizePersistedDraftThreads( |
| 1754 | worktreePath: normalizedWorktreePath, | 1780 | worktreePath: normalizedWorktreePath, |
| 1755 | envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), | 1781 | envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), |
| 1756 | startFromOrigin, | 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 | promotedTo, | 1793 | promotedTo, |
| 1758 | }; | 1794 | }; |
| 1759 | } | 1795 | } |
| ... | @@ -2453,6 +2489,15 @@ function toHydratedDraftThreadState( | ... | @@ -2453,6 +2489,15 @@ function toHydratedDraftThreadState( |
| 2453 | worktreePath: persistedDraftThread.worktreePath, | 2489 | worktreePath: persistedDraftThread.worktreePath, |
| 2454 | envMode: persistedDraftThread.envMode, | 2490 | envMode: persistedDraftThread.envMode, |
| 2455 | startFromOrigin: persistedDraftThread.startFromOrigin, | 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 | promotedTo: persistedDraftThread.promotedTo | 2501 | promotedTo: persistedDraftThread.promotedTo |
| 2457 | ? scopeThreadRef( | 2502 | ? scopeThreadRef( |
| 2458 | persistedDraftThread.promotedTo.environmentId as EnvironmentId, | 2503 | persistedDraftThread.promotedTo.environmentId as EnvironmentId, |
| ... | @@ -2707,11 +2752,23 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2707,11 +2752,23 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2707 | options.startFromOrigin === undefined | 2752 | options.startFromOrigin === undefined |
| 2708 | ? existing.startFromOrigin | 2753 | ? existing.startFromOrigin |
| 2709 | : options.startFromOrigin; | 2754 | : options.startFromOrigin; |
| 2755 | const environmentSelection = | ||
| 2756 | options.environmentSelection ?? | ||
| 2757 | (options.branch != null || options.worktreePath != null | ||
| 2758 | ? "manual" | ||
| 2759 | : existing.environmentSelection); | ||
| 2710 | const nextDraftThread: DraftThreadState = { | 2760 | const nextDraftThread: DraftThreadState = { |
| 2711 | threadId: existing.threadId, | 2761 | threadId: existing.threadId, |
| 2712 | environmentId: nextProjectRef.environmentId, | 2762 | environmentId: nextProjectRef.environmentId, |
| 2713 | projectId: nextProjectRef.projectId, | 2763 | projectId: nextProjectRef.projectId, |
| 2714 | logicalProjectKey: existing.logicalProjectKey, | 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 | createdAt: | 2772 | createdAt: |
| 2716 | options.createdAt === undefined | 2773 | options.createdAt === undefined |
| 2717 | ? existing.createdAt | 2774 | ? existing.createdAt |
| ... | @@ -2729,6 +2786,8 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2729,6 +2786,8 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2729 | nextDraftThread.environmentId === existing.environmentId && | 2786 | nextDraftThread.environmentId === existing.environmentId && |
| 2730 | nextDraftThread.projectId === existing.projectId && | 2787 | nextDraftThread.projectId === existing.projectId && |
| 2731 | nextDraftThread.logicalProjectKey === existing.logicalProjectKey && | 2788 | nextDraftThread.logicalProjectKey === existing.logicalProjectKey && |
| 2789 | nextDraftThread.environmentSelection === existing.environmentSelection && | ||
| 2790 | nextDraftThread.loadBalancedEnvironmentId === existing.loadBalancedEnvironmentId && | ||
| 2732 | nextDraftThread.createdAt === existing.createdAt && | 2791 | nextDraftThread.createdAt === existing.createdAt && |
| 2733 | nextDraftThread.runtimeMode === existing.runtimeMode && | 2792 | nextDraftThread.runtimeMode === existing.runtimeMode && |
| 2734 | nextDraftThread.interactionMode === existing.interactionMode && | 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,6 +61,23 @@ created in Settings can only be copied from the client that created them while |
| 61 | its Connections page stays open. If you leave or reload that page, create | 61 | its Connections page stays open. If you leave or reload that page, create |
| 62 | another link to share. | 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 | ### Tailscale HTTPS | 81 | ### Tailscale HTTPS |
| 65 | 82 | ||
| 66 | Join both devices to the same tailnet. In the desktop app, enable **Tailscale | 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,6 +3,10 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./load-balancing": { | ||
| 7 | "types": "./src/load-balancing.ts", | ||
| 8 | "default": "./src/load-balancing.ts" | ||
| 9 | }, | ||
| 6 | "./project-favicon-cache": { | 10 | "./project-favicon-cache": { |
| 7 | "types": "./src/projectFaviconCache.ts", | 11 | "types": "./src/projectFaviconCache.ts", |
| 8 | "default": "./src/projectFaviconCache.ts" | 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,6 +2,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import type { EnvironmentProject } from "./models.ts"; | 4 | import type { EnvironmentProject } from "./models.ts"; |
| 5 | import { chooseLoadBalancedEnvironment } from "../load-balancing.ts"; | ||
| 5 | import { | 6 | import { |
| 6 | buildProjectGroups, | 7 | buildProjectGroups, |
| 7 | derivePhysicalProjectKey, | 8 | derivePhysicalProjectKey, |
| ... | @@ -9,6 +10,70 @@ import { | ... | @@ -9,6 +10,70 @@ import { |
| 9 | } from "./projectGrouping.ts"; | 10 | } from "./projectGrouping.ts"; |
| 10 | 11 | ||
| 11 | const environmentId = EnvironmentId.make("environment"); | 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 | const repositoryIdentity = { | 77 | const repositoryIdentity = { |
| 13 | canonicalKey: "github.com/t3tools/t3code", | 78 | canonicalKey: "github.com/t3tools/t3code", |
| 14 | locator: { | 79 | locator: { |
packages/client-runtime/src/state/server.ts+7| ... | @@ -26,6 +26,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ... | @@ -26,6 +26,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 26 | import { | 26 | import { |
| 27 | createAtomCommandScheduler, | 27 | createAtomCommandScheduler, |
| 28 | createEnvironmentRpcCommand, | 28 | createEnvironmentRpcCommand, |
| 29 | createEnvironmentQueryAtomFamily, | ||
| 29 | createEnvironmentRpcQueryAtomFamily, | 30 | createEnvironmentRpcQueryAtomFamily, |
| 30 | createEnvironmentRpcSubscriptionAtomFamily, | 31 | createEnvironmentRpcSubscriptionAtomFamily, |
| 31 | createRuntimeCommand, | 32 | createRuntimeCommand, |
| ... | @@ -1018,6 +1019,12 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -1018,6 +1019,12 @@ export function createServerEnvironmentAtoms<R, E>( |
| 1018 | label: "environment-data:server:process-diagnostics", | 1019 | label: "environment-data:server:process-diagnostics", |
| 1019 | tag: WS_METHODS.serverGetProcessDiagnostics, | 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 | processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, { | 1028 | processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 1022 | label: "environment-data:server:process-resource-history", | 1029 | label: "environment-data:server:process-resource-history", |
| 1023 | tag: WS_METHODS.serverGetProcessResourceHistory, | 1030 | tag: WS_METHODS.serverGetProcessResourceHistory, |
packages/contracts/src/resourceTelemetry.ts+10| ... | @@ -6,6 +6,16 @@ import { DesktopUpdateStateSchema } from "./ipc.ts"; | ... | @@ -6,6 +6,16 @@ import { DesktopUpdateStateSchema } from "./ipc.ts"; |
| 6 | 6 | ||
| 7 | export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const; | 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 | export const ResourceTelemetryIoSemantics = Schema.Literals([ | 19 | export const ResourceTelemetryIoSemantics = Schema.Literals([ |
| 10 | "storage", | 20 | "storage", |
| 11 | "logical", | 21 | "logical", |
packages/contracts/src/rpc.ts+9| ... | @@ -210,6 +210,7 @@ import { | ... | @@ -210,6 +210,7 @@ import { |
| 210 | ServerUpsertKeybindingResult, | 210 | ServerUpsertKeybindingResult, |
| 211 | } from "./server.ts"; | 211 | } from "./server.ts"; |
| 212 | import { | 212 | import { |
| 213 | HostResourcesSnapshot, | ||
| 213 | ResourceTelemetryHistory, | 214 | ResourceTelemetryHistory, |
| 214 | ResourceTelemetryHistoryInput, | 215 | ResourceTelemetryHistoryInput, |
| 215 | ResourceTelemetryRetryResult, | 216 | ResourceTelemetryRetryResult, |
| ... | @@ -323,6 +324,7 @@ export const WS_METHODS = { | ... | @@ -323,6 +324,7 @@ export const WS_METHODS = { |
| 323 | serverDiscoverSourceControl: "server.discoverSourceControl", | 324 | serverDiscoverSourceControl: "server.discoverSourceControl", |
| 324 | serverGetTraceDiagnostics: "server.getTraceDiagnostics", | 325 | serverGetTraceDiagnostics: "server.getTraceDiagnostics", |
| 325 | serverGetProcessDiagnostics: "server.getProcessDiagnostics", | 326 | serverGetProcessDiagnostics: "server.getProcessDiagnostics", |
| 327 | serverGetHostResources: "server.getHostResources", | ||
| 326 | serverGetProcessResourceHistory: "server.getProcessResourceHistory", | 328 | serverGetProcessResourceHistory: "server.getProcessResourceHistory", |
| 327 | serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", | 329 | serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", |
| 328 | serverRetryResourceTelemetry: "server.retryResourceTelemetry", | 330 | serverRetryResourceTelemetry: "server.retryResourceTelemetry", |
| ... | @@ -538,6 +540,12 @@ const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDia | ... | @@ -538,6 +540,12 @@ const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDia |
| 538 | error: EnvironmentAuthorizationError, | 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 | const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, { | 549 | const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, { |
| 542 | payload: ServerProcessResourceHistoryInput, | 550 | payload: ServerProcessResourceHistoryInput, |
| 543 | success: ServerProcessResourceHistoryResult, | 551 | success: ServerProcessResourceHistoryResult, |
| ... | @@ -1197,6 +1205,7 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -1197,6 +1205,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1197 | WsServerDiscoverSourceControlRpc, | 1205 | WsServerDiscoverSourceControlRpc, |
| 1198 | WsServerGetTraceDiagnosticsRpc, | 1206 | WsServerGetTraceDiagnosticsRpc, |
| 1199 | WsServerGetProcessDiagnosticsRpc, | 1207 | WsServerGetProcessDiagnosticsRpc, |
| 1208 | WsServerGetHostResourcesRpc, | ||
| 1200 | WsServerGetProcessResourceHistoryRpc, | 1209 | WsServerGetProcessResourceHistoryRpc, |
| 1201 | WsServerGetResourceTelemetryHistoryRpc, | 1210 | WsServerGetResourceTelemetryHistoryRpc, |
| 1202 | WsServerRetryResourceTelemetryRpc, | 1211 | WsServerRetryResourceTelemetryRpc, |
packages/contracts/src/settings.test.ts+15| ... | @@ -135,6 +135,21 @@ describe("ClaudeSettings auto-compaction", () => { | ... | @@ -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 | describe("ClientSettings word wrap", () => { | 153 | describe("ClientSettings word wrap", () => { |
| 139 | it("defaults word wrap on", () => { | 154 | it("defaults word wrap on", () => { |
| 140 | expect(decodeClientSettings({}).wordWrap).toBe(true); | 155 | expect(decodeClientSettings({}).wordWrap).toBe(true); |
packages/contracts/src/settings.ts+9| ... | @@ -204,7 +204,14 @@ export const BrowserLinkTarget = Schema.Literals(["system", "app"]); | ... | @@ -204,7 +204,14 @@ export const BrowserLinkTarget = Schema.Literals(["system", "app"]); |
| 204 | export type BrowserLinkTarget = typeof BrowserLinkTarget.Type; | 204 | export type BrowserLinkTarget = typeof BrowserLinkTarget.Type; |
| 205 | export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system"; | 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 | export const ClientSettingsSchema = Schema.Struct({ | 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 | appearanceContrast: AppearanceContrast.pipe( | 215 | appearanceContrast: AppearanceContrast.pipe( |
| 209 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)), | 216 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)), |
| 210 | ), | 217 | ), |
| ... | @@ -1210,6 +1217,8 @@ export const ServerSettingsPatch = Schema.Struct({ | ... | @@ -1210,6 +1217,8 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1210 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; | 1217 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
| 1211 | 1218 | ||
| 1212 | export const ClientSettingsPatch = Schema.Struct({ | 1219 | export const ClientSettingsPatch = Schema.Struct({ |
| 1220 | loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), | ||
| 1221 | loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), | ||
| 1213 | appearanceContrast: Schema.optionalKey(AppearanceContrast), | 1222 | appearanceContrast: Schema.optionalKey(AppearanceContrast), |
| 1214 | panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs), | 1223 | panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs), |
| 1215 | browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting), | 1224 | browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting), |