Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const clientSettings: ClientSettings = {
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
legacySidebarEnabled: false,
loadBalancingEnabled: false,
loadBalancingWeights: { "environment-1": 75, "environment-2": 0 },
timestampFormat: "24-hour",
wordWrap: true,
};
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,
[WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope,
Expand Down
76 changes: 76 additions & 0 deletions apps/server/src/resourceTelemetry/HostResources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import * as NodeOS from "node:os";
import * as NodeTimersPromises from "node:timers/promises";
import type { HostResourcesSnapshot } from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";

const CACHE_MS = 5_000;

// All sockets share the sample. Idle servers do no polling or process scans.
let latest: HostResourcesSnapshot | undefined;
let pending: Promise<HostResourcesSnapshot> | undefined;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated

function readCpu() {
const cpus = NodeOS.cpus();
const cpu = cpus.reduce(
(sum, { times }) => ({
idle: sum.idle + times.idle,
total: sum.total + times.user + times.nice + times.sys + times.idle + times.irq,
}),
{ idle: 0, total: 0 },
);
return { ...cpu, count: cpus.length };
}

async function sample(
sampledAt: number,
platform: NodeJS.Platform,
readMeminfo: () => Promise<string>,
): Promise<HostResourcesSnapshot> {
const previousCpu = readCpu();
// CPU counters need two readings; a bounded demand-only sample also works on Windows.
await NodeTimersPromises.setTimeout(200);
const cpu = readCpu();
const totalDelta = cpu.total - previousCpu.total;
const idleDelta = cpu.idle - previousCpu.idle;
const cpuUtilization =
previousCpu.count === cpu.count && totalDelta > 0 && idleDelta >= 0
? Math.min(1, Math.max(0, 1 - idleDelta / totalDelta))
: null;
const totalMemoryBytes = NodeOS.totalmem();
let availableMemoryBytes = NodeOS.freemem();
if (platform === "linux") {
const meminfo = await readMeminfo();
const available = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1];
if (available) availableMemoryBytes = Number(available) * 1024;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
latest = {
sampledAt,
cpuUtilization,
cpuCount: cpu.count,
availableMemoryBytes: Math.min(totalMemoryBytes, Math.max(0, availableMemoryBytes)),
totalMemoryBytes,
};
return latest;
}

export const readHostResources = Effect.fn("HostResources.read")(function* () {
const sampledAt = DateTime.toEpochMillis(yield* DateTime.now);
if (latest && sampledAt >= latest.sampledAt && sampledAt - latest.sampledAt < CACHE_MS) {
return latest;
}
const fs = yield* FileSystem.FileSystem;
const platform = yield* HostProcessPlatform;
return yield* Effect.promise(() => {
pending ??= sample(sampledAt, platform, () =>
Effect.runPromise(
fs.readFileString("/proc/meminfo").pipe(Effect.catch(() => Effect.succeed(""))),
),
).finally(() => {
pending = undefined;
});
return pending;
});
});
Comment thread
maria-rcks marked this conversation as resolved.
Outdated
28 changes: 28 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6040,6 +6040,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("returns cached whole-host resources over websocket", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();
const wsUrl = yield* getWsServerUrl("/ws");
const [first, second] = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
Effect.all(
[
client[WS_METHODS.serverGetHostResources]({}),
client[WS_METHODS.serverGetHostResources]({}),
],
{ concurrency: "unbounded" },
),
),
);
assert.deepEqual(first, second);
assert.isAtLeast(first.sampledAt, 0);
assert.isAbove(first.cpuCount, 0);
assert.isAbove(first.totalMemoryBytes, 0);
assert.isAtLeast(first.availableMemoryBytes, 0);
assert.isAtMost(first.availableMemoryBytes, first.totalMemoryBytes);
if (first.cpuUtilization !== null) {
assert.isAtLeast(first.cpuUtilization, 0);
assert.isAtMost(first.cpuUtilization, 1);
}
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("routes websocket resource telemetry through the subscription", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts";
import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
import { readHostResources } from "./resourceTelemetry/HostResources.ts";
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
import * as UsageLimitSources from "./usage/UsageLimitSources.ts";
import * as UsageService from "./usage/UsageService.ts";
Expand Down Expand Up @@ -1998,6 +1999,10 @@ const makeWsRpcLayer = (
observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, {
"rpc.aggregate": "server",
}),
[WS_METHODS.serverGetHostResources]: (_input) =>
observeRpcEffect(WS_METHODS.serverGetHostResources, readHostResources(), {
"rpc.aggregate": "server",
}),
[WS_METHODS.serverGetProcessResourceHistory]: (input) =>
observeRpcEffect(
WS_METHODS.serverGetProcessResourceHistory,
Expand Down
34 changes: 31 additions & 3 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ interface BranchToolbarProps {
onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
startFromOrigin: boolean;
onStartFromOriginChange: (startFromOrigin: boolean) => void;
autoEnvironmentLabel?: string | undefined;
onAutoEnvironment?: (() => void) | undefined;
envLocked: boolean;
onCheckoutPullRequestRequest?: (reference: string) => void;
onComposerFocusRequest?: () => void;
Expand All @@ -66,6 +68,8 @@ interface BranchToolbarProps {
}

interface MobileRunContextSelectorProps {
autoEnvironmentLabel?: string | undefined;
onAutoEnvironment?: (() => void) | undefined;
envLocked: boolean;
envModeLocked: boolean;
environmentId: EnvironmentId;
Expand All @@ -81,6 +85,8 @@ interface MobileRunContextSelectorProps {
}

const MobileRunContextSelector = memo(function MobileRunContextSelector({
autoEnvironmentLabel,
onAutoEnvironment,
envLocked,
envModeLocked,
environmentId,
Expand Down Expand Up @@ -134,7 +140,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
data-composer-label-motion
className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity"
>
{showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel}
{autoEnvironmentLabel ??
(showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)}
</span>
</span>
</>
Expand Down Expand Up @@ -167,9 +174,24 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
<MenuGroup>
<MenuGroupLabel>Run on</MenuGroupLabel>
<MenuRadioGroup
value={environmentId}
onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)}
value={autoEnvironmentLabel ? "auto" : environmentId}
onValueChange={(value) =>
value === "auto"
? onAutoEnvironment?.()
: onEnvironmentChange(value as EnvironmentId)
}
>
{onAutoEnvironment && (
<MenuRadioItem
value="auto"
disabled={envLocked}
onClick={() => {
if (autoEnvironmentLabel) onAutoEnvironment?.();
}}
>
{autoEnvironmentLabel ?? "Balance load"}
</MenuRadioItem>
)}
{availableEnvironments.map((env) => (
<MenuRadioItem
key={env.environmentId}
Expand Down Expand Up @@ -421,6 +443,8 @@ export const BranchToolbar = memo(function BranchToolbar({
onActiveThreadBranchOverrideChange,
startFromOrigin,
onStartFromOriginChange,
autoEnvironmentLabel,
onAutoEnvironment,
envLocked,
onCheckoutPullRequestRequest,
onComposerFocusRequest,
Expand Down Expand Up @@ -518,6 +542,8 @@ export const BranchToolbar = memo(function BranchToolbar({
{showGitControls ? (
<div className="contents @3xl/composer-surface:hidden">
<MobileRunContextSelector
autoEnvironmentLabel={autoEnvironmentLabel}
onAutoEnvironment={onAutoEnvironment}
envLocked={envLocked}
envModeLocked={envModeLocked}
environmentId={environmentId}
Expand All @@ -544,6 +570,8 @@ export const BranchToolbar = memo(function BranchToolbar({
{showEnvironmentIndicator && availableEnvironments && (
<>
<BranchToolbarEnvironmentSelector
autoEnvironmentLabel={autoEnvironmentLabel}
onAutoEnvironment={onAutoEnvironment}
envLocked={envLocked}
environmentId={environmentId}
availableEnvironments={availableEnvironments}
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export function BranchToolbarBranchSelector({
// Thread branch mutation (colocated — only this component calls it)
// ---------------------------------------------------------------------------
const setThreadBranch = useCallback(
(branch: string | null, worktreePath: string | null) => {
(branch: string | null, worktreePath: string | null, automatic = false) => {
if (!activeThreadId || !activeProject) return;
if (serverSession && worktreePath !== activeWorktreePath) {
void stopThreadSession({
Expand Down Expand Up @@ -185,6 +185,7 @@ export function BranchToolbarBranchSelector({
branch,
worktreePath,
envMode: nextDraftEnvMode,
environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual",
Comment thread
cursor[bot] marked this conversation as resolved.
projectRef: scopeProjectRef(environmentId, activeProject.id),
});
},
Expand All @@ -200,6 +201,7 @@ export function BranchToolbarBranchSelector({
threadRef,
environmentId,
effectiveEnvMode,
draftThread?.environmentSelection,
stopThreadSession,
updateThreadMetadata,
],
Expand Down Expand Up @@ -506,7 +508,7 @@ export function BranchToolbarBranchSelector({
) {
return;
}
setThreadBranch(worktreeBaseBranchCandidate, null);
setThreadBranch(worktreeBaseBranchCandidate, null, true);
}, [
activeThreadBranch,
activeWorktreePath,
Expand Down
30 changes: 25 additions & 5 deletions apps/web/src/components/BranchToolbarEnvironmentSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
} from "./ui/select";

interface BranchToolbarEnvironmentSelectorProps {
autoEnvironmentLabel?: string | undefined;
onAutoEnvironment?: (() => void) | undefined;
envLocked: boolean;
environmentId: EnvironmentId;
availableEnvironments: readonly EnvironmentOption[];
Expand All @@ -24,6 +26,8 @@ interface BranchToolbarEnvironmentSelectorProps {
}

export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({
autoEnvironmentLabel,
onAutoEnvironment,
envLocked,
environmentId,
availableEnvironments,
Expand All @@ -34,12 +38,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
}, [availableEnvironments, environmentId]);

const environmentItems = useMemo(
() =>
availableEnvironments.map((env) => ({
() => [
...(onAutoEnvironment
? [{ value: "auto", label: autoEnvironmentLabel ?? "Balance load" }]
: []),
...availableEnvironments.map((env) => ({
value: env.environmentId,
label: env.label,
})),
[availableEnvironments],
],
[availableEnvironments, autoEnvironmentLabel, onAutoEnvironment],
);

// The static label carries the xs control's height (h-7 sm:h-6) as well as
Expand Down Expand Up @@ -75,8 +83,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
return (
<Select
modal={false}
value={environmentId}
onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)}
value={autoEnvironmentLabel ? "auto" : environmentId}
onValueChange={(value) =>
value === "auto" ? onAutoEnvironment?.() : onEnvironmentChange(value as EnvironmentId)
}
items={environmentItems}
>
<SelectTrigger
Expand Down Expand Up @@ -105,6 +115,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
<SelectPopup alignItemWithTrigger={false} {...composerFloatingLayerProps}>
<SelectGroup>
<SelectGroupLabel>Run on</SelectGroupLabel>
{onAutoEnvironment && (
<SelectItem
value="auto"
onClick={() => {
if (autoEnvironmentLabel) onAutoEnvironment?.();
}}
>
{autoEnvironmentLabel ?? "Balance load"}
</SelectItem>
)}
{availableEnvironments.map((env) => (
<SelectItem key={env.environmentId} value={env.environmentId}>
<span className="inline-flex items-center gap-1.5">
Expand Down
Loading
Loading