authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-06 02:33:13-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 02:33:13-03:00
log420fd76f60433fe05b8d2c76f4fbde430dc49968
tree04aec1843d95a1873aff936760a7ade23c0e4b15
parent9f40b2f563c662b43887b11ff99c466fe871c1af
signature Signed by PGP key B5690EEEBB952194

feat(connections): balance new threads across connected machines (#9895)


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 @@
1import * as NodeOS from "node:os";
2import type { HostResourcesSnapshot } from "@t3tools/contracts";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Cache from "effect/Cache";
5import * as Context from "effect/Context";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11
12export class HostResources extends Context.Service<
13 HostResources,
14 { readonly read: Effect.Effect<HostResourcesSnapshot> }
15>()("t3/resourceTelemetry/HostResources") {}
16
17function 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
29function 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
41export 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
93export 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";
165import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";165import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
166import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";166import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
167import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";167import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
168import * as HostResources from "./resourceTelemetry/HostResources.ts";
168import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";169import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
169import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";170import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
170import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts";171import * 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 );
61566158
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";
115import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts";115import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts";
116import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";116import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
117import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";117import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
118import * as HostResources from "./resourceTelemetry/HostResources.ts";
118import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";119import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
119import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";120import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
120import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts";121import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts";
...@@ -199,6 +200,7 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe(...@@ -199,6 +200,7 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe(
199const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive));200const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive));
200201
201const ResourceDiagnosticsLayerLive = Layer.mergeAll(202const 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";
135import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";135import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
136import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";136import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
137import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";137import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
138import * as HostResources from "./resourceTelemetry/HostResources.ts";
138import * as AnalyticsService from "./telemetry/AnalyticsService.ts";139import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
139import * as UsageLimitSources from "./usage/UsageLimitSources.ts";140import * as UsageLimitSources from "./usage/UsageLimitSources.ts";
140import * as UsageService from "./usage/UsageService.ts";141import * 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";
10import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";11import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1112
...@@ -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}
6770
68interface MobileRunContextSelectorProps {71interface 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}
8287
83const MobileRunContextSelector = memo(function MobileRunContextSelector({88const 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 4px121 // 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 <EnvironmentMachineIcon124 {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-motion145 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 <MenuRadioGroup181 <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 <MenuRadioItem206 <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 <MobileRunContextSelector554 <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 <BranchToolbarEnvironmentSelector582 <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 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import { ScaleIcon } from "lucide-react";
2import { memo, useMemo } from "react";3import { memo, useMemo } from "react";
34
4import type { EnvironmentOption } from "./BranchToolbar.logic";5import type { EnvironmentOption } from "./BranchToolbar.logic";
...@@ -15,6 +16,8 @@ import {...@@ -15,6 +16,8 @@ import {
15} from "./ui/select";16} from "./ui/select";
1617
17interface BranchToolbarEnvironmentSelectorProps {18interface 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}
2528
26export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({29export 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]);
3540
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 );
4453
45 // The static label carries the xs control's height (h-7 sm:h-6) as well as54 // 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 <Select85 <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 <SelectTrigger93 <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-control98 data-composer-context-control
88 >99 >
89 <EnvironmentMachineIcon100 {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 <span108 <span
94 data-composer-label109 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 @@
1import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment";
1import type { UsageLimitSourceSnapshots } from "@t3tools/contracts";2import type { UsageLimitSourceSnapshots } from "@t3tools/contracts";
2import {3import {
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 );
32803281
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 a3388 // Handle environment change for draft threads. When the user picks a
3282 // different environment we update the draft context to point at the physical3389 // 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 }, []);
10431043
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 }
10911095
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";
61import { searchableSetting } from "./settingsSearch";61import { searchableSetting } from "./settingsSearch";
62import { EnvironmentIconPicker } from "./EnvironmentIconPicker";62import { EnvironmentIconPicker } from "./EnvironmentIconPicker";
63import { LoadBalancingSettings } from "./LoadBalancingSettings";
63import { Input } from "../ui/input";64import { Input } from "../ui/input";
64import { CommandShortcut } from "../ui/command";65import { CommandShortcut } from "../ui/command";
65import {66import {
...@@ -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 @@
1import { connectionStatusText } from "@t3tools/client-runtime/connection";
2
3import {
4 useClientSettings,
5 useClientSettingsHydrated,
6 useUpdateClientSettings,
7} from "~/hooks/useSettings";
8import type { EnvironmentPresentation } from "~/state/environments";
9import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
10import { Switch } from "../ui/switch";
11import { SettingsRow, SettingsSection } from "./settingsLayout";
12import { searchableSetting } from "./settingsSearch";
13
14const 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
21export 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 });
16251625
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 logical1551 // A project change (including switching environments within a logical
...@@ -1560,11 +1572,23 @@ function createDraftThreadState(...@@ -1560,11 +1572,23 @@ function createDraftThreadState(
1560 options?.startFromOrigin === undefined1572 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.promotedTo2501 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 === undefined2752 options.startFromOrigin === undefined
2708 ? existing.startFromOrigin2753 ? 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 === undefined2773 options.createdAt === undefined
2717 ? existing.createdAt2774 ? 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 @@
1import { RegistryContext, useAtomValue } from "@effect/atom-react";
2import { chooseLoadBalancedEnvironment } from "@t3tools/client-runtime/load-balancing";
3import type { EnvironmentId } from "@t3tools/contracts";
4import { Atom } from "effect/unstable/reactivity";
5import { useCallback, useContext, useMemo } from "react";
6
7import { serverEnvironment } from "../state/server";
8
9/** Only mounted for unresolved automatic drafts, so idle clients do not poll hosts. */
10export 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
61its Connections page stays open. If you leave or reload that page, create61its Connections page stays open. If you leave or reload that page, create
62another link to share.62another link to share.
6363
64### Balance new threads across machines
65
66Auto balance is off by default. On web and desktop, enable it in
67**Settings → Connections → Load balancing** to automatically choose a machine for
68new threads in projects grouped across connected environments.
69Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and
70memory available, **Less often** to reduce its share, or **Manual only** to exclude
71it from automatic selection. These are preferences, not fixed traffic percentages.
72Preferences are saved separately in each client.
73
74The composer checks eligible machines when choosing a draft's environment, then keeps
75that choice stable. Choose **Auto balance** again to check current resources, or choose
76a specific machine to override it. Choosing a branch or worktree also keeps the draft
77on that machine. Existing threads stay where they started. If resource checks are
78unavailable or all eligible machines are full, choose a machine manually to continue.
79Mobile keeps its manual environment selection.
80
64### Tailscale HTTPS81### Tailscale HTTPS
6582
66Join both devices to the same tailnet. In the desktop app, enable **Tailscale83Join 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 @@
1import type { HostResourcesSnapshot } from "@t3tools/contracts";
2
3/** Callers supply only connected machines hosting the project and selected provider. */
4export 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";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import type { EnvironmentProject } from "./models.ts";4import type { EnvironmentProject } from "./models.ts";
5import { chooseLoadBalancedEnvironment } from "../load-balancing.ts";
5import {6import {
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";
1011
11const environmentId = EnvironmentId.make("environment");12const environmentId = EnvironmentId.make("environment");
13
14describe("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});
12const repositoryIdentity = {77const 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";
26import {26import {
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";
66
7export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const;7export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const;
88
9/** Whole-host capacity, independent of T3's process diagnostics. */
10export 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});
17export type HostResourcesSnapshot = typeof HostResourcesSnapshot.Type;
18
9export const ResourceTelemetryIoSemantics = Schema.Literals([19export 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";
212import {212import {
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});
540542
543const WsServerGetHostResourcesRpc = Rpc.make(WS_METHODS.serverGetHostResources, {
544 payload: Schema.Struct({}),
545 success: HostResourcesSnapshot,
546 error: EnvironmentAuthorizationError,
547});
548
541const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, {549const 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});
137137
138describe("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
138describe("ClientSettings word wrap", () => {153describe("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"]);
204export type BrowserLinkTarget = typeof BrowserLinkTarget.Type;204export type BrowserLinkTarget = typeof BrowserLinkTarget.Type;
205export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system";205export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system";
206206
207export const LoadBalancingWeights = Schema.Record(
208 TrimmedNonEmptyString,
209 Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })),
210);
211
207export const ClientSettingsSchema = Schema.Struct({212export 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({
1210export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;1217export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
12111218
1212export const ClientSettingsPatch = Schema.Struct({1219export 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),