From c3f5a86b4daa7e4420f09f5ad18a1c71ac9b8ff5 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Mon, 17 Aug 2026 00:01:29 +0200 Subject: [PATCH 1/6] feat: let users rename environments Store environment names on the server so every client and T3 Connect share one label. Add authorized rename controls for web, desktop, and mobile with live propagation. --- apps/mobile/src/Stack.tsx | 11 ++ .../connection/ConnectionEnvironmentRow.tsx | 63 ++++--- .../connection/ConnectionsRouteScreen.tsx | 3 + .../SettingsEnvironmentRenameRouteScreen.tsx | 113 +++++++++++ .../SettingsEnvironmentsRouteScreen.tsx | 3 + apps/mobile/src/state/environments.ts | 9 +- apps/mobile/src/state/session.ts | 10 +- .../src/cloud/EnvironmentLabelRelaySync.ts | 81 ++++++++ apps/server/src/cloud/http.test.ts | 2 + .../src/environment/ServerEnvironment.test.ts | 34 +++- .../src/environment/ServerEnvironment.ts | 12 +- .../server/src/mcp/McpSessionRegistry.test.ts | 1 + .../src/relay/AgentAwarenessRelay.test.ts | 2 + apps/server/src/relay/AgentAwarenessRelay.ts | 7 +- .../src/relay/relayEnvironmentClient.ts | 6 + apps/server/src/serverRuntimeStartup.ts | 14 ++ apps/server/src/ws.ts | 24 ++- .../settings/ConnectionsSettings.tsx | 177 ++++++++++++++++++ apps/web/src/state/environments.ts | 2 +- .../AgentActivityPublisher.test.ts | 1 + .../agentActivity/MobileRegistrations.test.ts | 1 + .../environments/EnvironmentConnector.test.ts | 1 + .../environments/EnvironmentLinker.test.ts | 1 + .../src/environments/EnvironmentLinks.test.ts | 42 +++++ .../src/environments/EnvironmentLinks.ts | 39 ++++ infra/relay/src/http/Api.test.ts | 1 + infra/relay/src/http/Api.ts | 20 +- .../client-runtime/src/state/server.test.ts | 34 ++++ packages/client-runtime/src/state/server.ts | 43 +++++ packages/contracts/src/relay.ts | 11 ++ packages/contracts/src/server.ts | 15 ++ packages/contracts/src/settings.test.ts | 17 ++ packages/contracts/src/settings.ts | 4 + 33 files changed, 760 insertions(+), 44 deletions(-) create mode 100644 apps/mobile/src/features/settings/SettingsEnvironmentRenameRouteScreen.tsx create mode 100644 apps/server/src/cloud/EnvironmentLabelRelaySync.ts create mode 100644 apps/server/src/relay/relayEnvironmentClient.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 20bba1f6062d..315f14d75085 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -54,6 +54,7 @@ import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppea import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; +import { SettingsEnvironmentRenameRouteScreen } from "./features/settings/SettingsEnvironmentRenameRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; @@ -630,6 +631,16 @@ export const RootStack = createNativeStackNavigator({ sheetGrabberVisible: true, }, }), + EnvironmentRename: createNativeStackScreen({ + screen: SettingsEnvironmentRenameRouteScreen, + linking: "environment-rename", + options: { + title: "Rename Environment", + ...FORM_SHEET_PRESENTATION_OPTIONS, + sheetAllowedDetents: [0.4], + sheetGrabberVisible: true, + }, + }), NewTaskSheet: createNativeStackScreen({ screen: NewTaskSheetStack, linking: "new", diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 03a0eb5025f6..3ee47722e94a 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -1,7 +1,7 @@ import { SymbolView } from "../../components/AppSymbol"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { AuthOrchestrationOperateScope, type EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useState } from "react"; @@ -14,6 +14,7 @@ import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; +import { useEnvironmentSessionState } from "../../state/session"; function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { return connectionStatusText({ @@ -29,12 +30,12 @@ export function ConnectionEnvironmentRow(props: { readonly onToggle: () => void; readonly onReconnect: (environmentId: EnvironmentId) => void; readonly onRemove: (environmentId: EnvironmentId) => void; + readonly onRename: (environmentId: EnvironmentId) => void; readonly onUpdate: ( environmentId: EnvironmentId, updates: { readonly label: string; readonly displayUrl: string }, ) => Promise>; }) { - const [label, setLabel] = useState(props.environment.environmentLabel); const [url, setUrl] = useState(props.environment.displayUrl); const mutedColor = useThemeColor("--color-icon-subtle"); @@ -46,9 +47,16 @@ export function ConnectionEnvironmentRow(props: { const isRetrying = props.environment.connectionState === "connecting" || props.environment.connectionState === "reconnecting"; + const sessionState = useEnvironmentSessionState(props.environment.environmentId); + const canRename = + props.environment.connectionState === "connected" && + Boolean( + sessionState.data?.authenticated && + sessionState.data.scopes?.includes(AuthOrchestrationOperateScope), + ); const handleSave = useCallback(async () => { const result = await props.onUpdate(props.environment.environmentId, { - label: label.trim(), + label: "", displayUrl: url.trim(), }); if (AsyncResult.isSuccess(result)) { @@ -60,7 +68,7 @@ export function ConnectionEnvironmentRow(props: { "Could not update environment", error instanceof Error ? error.message : "The environment could not be updated.", ); - }, [label, url, props]); + }, [url, props]); return ( @@ -114,15 +122,30 @@ export function ConnectionEnvironmentRow(props: { ) : null} - + + {canRename ? ( + { + event.stopPropagation(); + props.onRename(props.environment.environmentId); + }} + > + + + ) : null} + + {props.expanded ? ( @@ -137,20 +160,6 @@ export function ConnectionEnvironmentRow(props: { ) : ( <> - - - Label - - - - URL diff --git a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx index 464477ffc874..0bdf0f66b4a0 100644 --- a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx @@ -79,6 +79,9 @@ export function ConnectionsRouteScreen() { onReconnect={onReconnectEnvironment} onRemove={onRemoveEnvironmentPress} onUpdate={onUpdateEnvironment} + onRename={(environmentId) => + navigation.navigate("EnvironmentRename", { environmentId }) + } /> ))} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentRenameRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentRenameRouteScreen.tsx new file mode 100644 index 000000000000..994e90302d32 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsEnvironmentRenameRouteScreen.tsx @@ -0,0 +1,113 @@ +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useState } from "react"; +import { ActivityIndicator, Alert, Pressable, View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useRemoteConnections } from "../../state/use-remote-environment-registry"; + +export type SettingsEnvironmentRenameParams = { + readonly environmentId: EnvironmentId; +}; + +export function SettingsEnvironmentRenameRouteScreen({ + route, +}: StaticScreenProps) { + const navigation = useNavigation(); + const { connectedEnvironments } = useRemoteConnections(); + const environment = connectedEnvironments.find( + (candidate) => candidate.environmentId === route.params.environmentId, + ); + const [label, setLabel] = useState(environment?.environmentLabel ?? ""); + const [saving, setSaving] = useState(false); + const renameEnvironment = useAtomCommand(serverEnvironment.updateEnvironmentLabel, { + reportFailure: false, + }); + + const save = async () => { + const nextLabel = label.trim(); + const duplicate = + nextLabel.length > 0 && + connectedEnvironments.some( + (candidate) => + candidate.environmentId !== route.params.environmentId && + candidate.environmentLabel === nextLabel, + ); + if (duplicate) { + Alert.alert( + "Duplicate environment name", + `Another environment is already named "${nextLabel}". Use this name for both environments?`, + [ + { text: "Cancel", style: "cancel" }, + { text: "Use Name", onPress: () => void submit(nextLabel) }, + ], + ); + return; + } + await submit(nextLabel); + }; + + const submit = async (nextLabel: string) => { + setSaving(true); + const result = await renameEnvironment({ + environmentId: route.params.environmentId, + input: nextLabel, + }); + setSaving(false); + if (AsyncResult.isSuccess(result)) { + navigation.goBack(); + return; + } + const error = Cause.squash(result.cause); + Alert.alert( + "Could not rename environment", + error instanceof Error ? error.message : "The environment name was not saved.", + ); + }; + + return ( + + + + Clear the name to use the environment's machine name. + + void save()} + className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-base text-foreground" + /> + + + navigation.goBack()} + > + Cancel + + void save()} + > + {saving ? ( + + ) : ( + Save + )} + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index aa30242ea72a..9c090b2ed487 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -145,6 +145,9 @@ export function SettingsEnvironmentsRouteScreen() { onReconnect={onReconnectEnvironment} onRemove={onRemoveEnvironmentPress} onUpdate={handleUpdateEnvironment} + onRename={(environmentId) => + navigation.navigate("EnvironmentRename", { environmentId }) + } /> ))} diff --git a/apps/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts index 88d80631ad31..935aad71c05f 100644 --- a/apps/mobile/src/state/environments.ts +++ b/apps/mobile/src/state/environments.ts @@ -21,11 +21,16 @@ export function projectEnvironmentPresentation( environmentId: EnvironmentId, presentation: BaseEnvironmentPresentation, ): EnvironmentPresentation { + const displayUrl = connectionCatalogDisplayUrl(presentation.entry); return { ...presentation, environmentId, - label: presentation.entry.target.label, - displayUrl: connectionCatalogDisplayUrl(presentation.entry), + label: + presentation.serverConfig?.environment.label ?? + (presentation.entry.target._tag === "RelayConnectionTarget" + ? presentation.entry.target.label + : (displayUrl ?? "Environment")), + displayUrl, relayManaged: presentation.entry.target._tag === "RelayConnectionTarget", }; } diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts index 747ab7c72ee2..325a0c2d2e21 100644 --- a/apps/mobile/src/state/session.ts +++ b/apps/mobile/src/state/session.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -19,3 +19,11 @@ export function usePreparedConnection(environmentId: EnvironmentId | null) { : environmentSession.preparedConnectionValueAtom(environmentId), ); } + +export function useEnvironmentSessionState(environmentId: EnvironmentId) { + const result = useAtomValue(environmentSession.sessionStateAtom(environmentId)); + return { + data: Option.getOrNull(AsyncResult.value(result)), + isPending: result.waiting, + }; +} diff --git a/apps/server/src/cloud/EnvironmentLabelRelaySync.ts b/apps/server/src/cloud/EnvironmentLabelRelaySync.ts new file mode 100644 index 000000000000..8bdd6b1c081f --- /dev/null +++ b/apps/server/src/cloud/EnvironmentLabelRelaySync.ts @@ -0,0 +1,81 @@ +import { RelayApi } from "@t3tools/contracts/relay"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { relayEnvironmentClient } from "../relay/relayEnvironmentClient.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_URL_SECRET } from "./config.ts"; + +const retrySchedule = Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.upTo({ duration: "10 minutes" }), +); + +export const runEnvironmentLabelRelaySync = Effect.fn("runEnvironmentLabelRelaySync")(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const environment = yield* ServerEnvironment.ServerEnvironment; + const settings = yield* ServerSettings.ServerSettingsService; + + const readSecretString = (name: string) => + secrets.get(name).pipe( + Effect.map( + Option.match({ + onNone: () => null, + onSome: (bytes) => new TextDecoder().decode(bytes), + }), + ), + ); + + const synchronize = Effect.fn("synchronizeEnvironmentLabel")(function* ( + environmentLabel: string, + ) { + // Apply the triggering setting before reading the descriptor. The + // general descriptor watcher runs independently and may not have seen + // this settings event yet. + yield* environment.setEnvironmentLabel(environmentLabel); + const [relayUrl, environmentCredential] = yield* Effect.all([ + readSecretString(RELAY_URL_SECRET), + readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + if (!relayUrl || !environmentCredential) return; + + const descriptor = yield* environment.getDescriptor; + const client = yield* HttpApiClient.make(RelayApi, { + baseUrl: relayUrl, + transformClient: relayEnvironmentClient(environmentCredential), + }).pipe(Effect.provide(FetchHttpClient.layer)); + yield* client.server.updateEnvironmentLabel({ + params: { environmentId: descriptor.environmentId }, + payload: { label: descriptor.label }, + }); + yield* Effect.logDebug("synchronized environment label with relay", { + environmentId: descriptor.environmentId, + }); + }); + + const changes = yield* settings.subscribeChanges; + const initialSettings = yield* settings.getSettings; + const synchronizeWithRetry = (environmentLabel: string) => + synchronize(environmentLabel).pipe( + Effect.retry({ schedule: retrySchedule }), + Effect.catch((cause) => + Effect.logWarning("failed to synchronize environment label with relay", { cause }), + ), + ); + + yield* synchronizeWithRetry(initialSettings.environmentLabel); + yield* changes.pipe( + Stream.map((next) => next.environmentLabel), + Stream.changes, + Stream.runForEach(synchronizeWithRetry), + ); +}); diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 0f24e6f34176..b70272f683f8 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -201,6 +201,7 @@ describe("reconcileDesiredCloudLink", () => { Effect.provideService( ServerEnvironment.ServerEnvironment, ServerEnvironment.ServerEnvironment.of({ + setEnvironmentLabel: unusedSecretStoreOperation, getEnvironmentId: unusedSecretStoreOperation(), getDescriptor: unusedSecretStoreOperation(), }), @@ -293,6 +294,7 @@ describe("releaseManagedTunnelOnShutdown", () => { Effect.provideService( ServerEnvironment.ServerEnvironment, ServerEnvironment.ServerEnvironment.of({ + setEnvironmentLabel: unusedSecretStoreOperation, getEnvironmentId: Effect.succeed(EnvironmentId.make("env_123")), getDescriptor: Effect.die("unused"), }), diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index ee30d987591d..635c1271ae0a 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -8,6 +9,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { SERVICE_LAUNCHER_CONTEXT_ENV } from "../cloud/serviceProtocol.ts"; import { PUBLISH_AGENT_ACTIVITY_SECRET, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, @@ -70,7 +72,15 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { } satisfies ServerConfig.ServerConfig["Service"]; }); -it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { +const testNodeServices = Layer.merge( + NodeServices.layer, + Layer.succeed(HostProcessEnvironment, { + ...process.env, + [SERVICE_LAUNCHER_CONTEXT_ENV]: undefined, + }), +); + +it.layer(testNodeServices)("ServerEnvironmentLive", (it) => { it.effect("persists the environment id across service restarts", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -142,6 +152,28 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }), ); + it.effect("uses a custom label until it is cleared", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-label-test-", + }); + + const labels = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const reported = (yield* serverEnvironment.getDescriptor).label; + yield* serverEnvironment.setEnvironmentLabel("Build server"); + const custom = (yield* serverEnvironment.getDescriptor).label; + yield* serverEnvironment.setEnvironmentLabel(""); + const reset = (yield* serverEnvironment.getDescriptor).label; + return { reported, custom, reset }; + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); + + expect(labels.custom).toBe("Build server"); + expect(labels.reset).toBe(labels.reported); + }), + ); + it.effect("structures persisted environment id filesystem failures", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 45dc0ee9cfd5..80509bf010b9 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -6,6 +6,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import packageJson from "../../package.json" with { type: "json" }; @@ -35,6 +36,7 @@ export class ServerEnvironment extends Context.Service< { readonly getEnvironmentId: Effect.Effect; readonly getDescriptor: Effect.Effect; + readonly setEnvironmentLabel: (label: string) => Effect.Effect; } >()("t3/environment/ServerEnvironment") {} @@ -72,6 +74,7 @@ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const hostPlatform = yield* HostProcessPlatform; const hostArchitecture = yield* HostProcessArchitecture; + const environmentLabel = yield* Ref.make(""); const readPersistedEnvironmentId = Effect.gen(function* () { const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe( @@ -159,12 +162,17 @@ export const make = Effect.gen(function* () { return ServerEnvironment.of({ getEnvironmentId: Effect.succeed(environmentId), + setEnvironmentLabel: (label) => Ref.set(environmentLabel, label), // The publish opt-in and relay link change at runtime (`t3 connect // publish`, the client settings toggle), so the capability is read per // descriptor request rather than baked in at startup. - getDescriptor: readAgentActivityPublishingActive(secrets).pipe( - Effect.map((agentActivityPublishing) => ({ + getDescriptor: Effect.all({ + agentActivityPublishing: readAgentActivityPublishingActive(secrets), + customLabel: Ref.get(environmentLabel), + }).pipe( + Effect.map(({ agentActivityPublishing, customLabel }) => ({ ...descriptor, + label: customLabel || descriptor.label, capabilities: { ...descriptor.capabilities, agentActivityPublishing }, })), ), diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 1d8aead99d0d..801d874f1f50 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -15,6 +15,7 @@ const makeFakeHttpServer = (hostname: string, port = 43123) => }); const fakeHttpServer = makeFakeHttpServer("127.0.0.1"); const fakeEnvironment = ServerEnvironment.ServerEnvironment.of({ + setEnvironmentLabel: () => Effect.void, getEnvironmentId: Effect.succeed(environmentId), getDescriptor: Effect.die("unused"), }); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 74a4de594a15..a97f610d67ee 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -509,6 +509,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const layer = Layer.mergeAll( Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), Layer.succeed(ServerEnvironment.ServerEnvironment, { + setEnvironmentLabel: () => Effect.void, getEnvironmentId: Effect.succeed(environmentId), getDescriptor: Effect.succeed(descriptor), }), @@ -659,6 +660,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const layer = Layer.mergeAll( Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), Layer.succeed(ServerEnvironment.ServerEnvironment, { + setEnvironmentLabel: () => Effect.void, getEnvironmentId: Effect.succeed(environmentId), getDescriptor: Effect.succeed(descriptor), }), diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 5127ecf7d359..40a63d828863 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -29,8 +29,6 @@ import * as Ref from "effect/Ref"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; -import * as HttpClient from "effect/unstable/http/HttpClient"; -import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -46,6 +44,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { forkParked } from "../serverActivation.ts"; +import { relayEnvironmentClient } from "./relayEnvironmentClient.ts"; export class AgentAwarenessRelay extends Context.Service< AgentAwarenessRelay, @@ -133,10 +132,6 @@ export function sanitizeRelayAgentActivityState( return detail ? { ...rest, detail } : rest; } -function relayEnvironmentClient(token: string) { - return HttpClient.mapRequest(HttpClientRequest.setHeader("authorization", `Bearer ${token}`)); -} - function deliveryStats( deliveries: ReadonlyArray<{ readonly ok: boolean; diff --git a/apps/server/src/relay/relayEnvironmentClient.ts b/apps/server/src/relay/relayEnvironmentClient.ts new file mode 100644 index 000000000000..178a5efdcf7c --- /dev/null +++ b/apps/server/src/relay/relayEnvironmentClient.ts @@ -0,0 +1,6 @@ +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +export function relayEnvironmentClient(token: string) { + return HttpClient.mapRequest(HttpClientRequest.setHeader("authorization", `Bearer ${token}`)); +} diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 5db2b75556ee..9a87563b097d 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -21,8 +21,10 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; +import { runEnvironmentLabelRelaySync } from "./cloud/EnvironmentLabelRelaySync.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; @@ -355,6 +357,18 @@ export const make = (options?: StartupOptions) => ); const welcomeBase = yield* resolveWelcomeBase; + yield* serverEnvironment.setEnvironmentLabel( + (yield* serverSettings.getSettings).environmentLabel, + ); + yield* serverSettings.streamChanges.pipe( + Stream.runForEach((settings) => + serverEnvironment.setEnvironmentLabel(settings.environmentLabel), + ), + Scope.provide(reactorScope), + Effect.forkScoped, + ); + yield* runEnvironmentLabelRelaySync().pipe(Scope.provide(reactorScope), Effect.forkScoped); + const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: preparing welcome payload"); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 56ea24a4a8b8..956d913d509d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2191,11 +2191,26 @@ const makeWsRpcLayer = ( Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), ); const settingsUpdates = serverSettings.streamChanges.pipe( - Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), Stream.map((settings) => ({ version: 1 as const, type: "settingsUpdated" as const, - payload: { settings }, + payload: { + settings: ServerSettings.redactServerSettingsForClient(settings), + }, + })), + ); + const environmentLabelUpdates = serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.environmentLabel), + Stream.changes, + Stream.mapEffect((environmentLabel) => + serverEnvironment + .setEnvironmentLabel(environmentLabel) + .pipe(Effect.andThen(serverEnvironment.getDescriptor)), + ), + Stream.map((environment) => ({ + version: 1 as const, + type: "environmentLabelUpdated" as const, + payload: { label: environment.label }, })), ); @@ -2205,7 +2220,10 @@ const makeWsRpcLayer = ( const liveUpdates = Stream.merge( keybindingsUpdates, - Stream.merge(providerStatuses, settingsUpdates), + Stream.merge( + providerStatuses, + Stream.merge(settingsUpdates, environmentLabelUpdates), + ), ); return Stream.concat( diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 822dab797fea..a60a660897aa 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,9 +1,12 @@ import { + CheckIcon, ChevronsLeftRightEllipsisIcon, + PencilIcon, PlusIcon, QrCodeIcon, RefreshCwIcon, TerminalIcon, + XIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react"; @@ -126,6 +129,7 @@ import { } from "~/state/environments"; import { useAtomCommand } from "../../state/use-atom-command"; import { serverEnvironment } from "~/state/server"; +import { useEnvironmentSessionState } from "~/state/session"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; @@ -1333,13 +1337,135 @@ function NetworkAccessDescription({ type SavedBackendListRowProps = { environment: EnvironmentPresentation; + environmentLabels: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly label: string; + }>; removingEnvironmentId: EnvironmentId | null; onConnect: (environmentId: EnvironmentId) => void; onRemove: (environmentId: EnvironmentId) => void; }; +function EnvironmentLabelControl({ + environmentId, + label, + environmentLabels, + canRename, +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly environmentLabels: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly label: string; + }>; + readonly canRename: boolean; +}) { + const renameEnvironment = useAtomCommand(serverEnvironment.updateEnvironmentLabel, { + reportFailure: false, + }); + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(label); + const [saving, setSaving] = useState(false); + + if (!canRename) return null; + + const cancel = () => { + setValue(label); + setEditing(false); + }; + const save = async () => { + const nextLabel = value.trim(); + const duplicate = + nextLabel.length > 0 && + environmentLabels.some( + (environment) => + environment.environmentId !== environmentId && environment.label === nextLabel, + ); + if ( + duplicate && + !window.confirm( + `Another environment is already named “${nextLabel}”. Use this name for both environments?`, + ) + ) { + return; + } + + setSaving(true); + const result = await renameEnvironment({ environmentId, input: nextLabel }); + setSaving(false); + if (result._tag === "Success") { + setEditing(false); + return; + } + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not rename environment", + description: + error instanceof Error ? error.message : "The environment name was not saved.", + }), + ); + } + }; + + if (!editing) { + return ( + + ); + } + + return ( +
{ + event.preventDefault(); + void save(); + }} + > + setValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") cancel(); + }} + /> + + +
+ ); +} + function SavedBackendListRow({ environment, + environmentLabels, removingEnvironmentId, onConnect, onRemove, @@ -1347,6 +1473,13 @@ function SavedBackendListRow({ const environmentId = environment.environmentId; const connectionState = environment.connection.phase; const isConnected = connectionState === "connected"; + const sessionState = useEnvironmentSessionState(environmentId); + const canRename = + isConnected && + Boolean( + sessionState.data?.authenticated && + sessionState.data.scopes?.includes(AuthOrchestrationOperateScope), + ); const isConnecting = connectionState === "connecting" || connectionState === "reconnecting"; const stateDotClassName = connectionState === "connected" @@ -1419,6 +1552,12 @@ function SavedBackendListRow({ } />

{environment.label}

+ {metadataBits.length > 0 ? (

{metadataBits.join(" · ")}

@@ -1863,6 +2002,15 @@ export function ConnectionsSettings() { ); const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; + const canRenamePrimary = currentSessionScopes?.includes(AuthOrchestrationOperateScope) ?? false; + const environmentLabels = useMemo( + () => + environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + })), + [environments], + ); const authAccessChanges = useEnvironmentQuery( canManageLocalBackend && primaryEnvironmentId !== null ? authEnvironment.accessChanges({ @@ -3000,6 +3148,20 @@ export function ConnectionsSettings() { {canManageLocalBackend ? ( <> + {primaryEnvironment && primaryEnvironmentId ? ( + + } + /> + ) : null} {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( ) : ( + {primaryEnvironment && primaryEnvironmentId ? ( + + } + /> + ) : null} = {}, ): EnvironmentLinks.EnvironmentLinks["Service"] { return { + updateLabel: () => Effect.void, upsert: () => Effect.void, listUsersForEnvironment: () => Effect.succeed(["dev:julius"]), listDeliveryUsersForEnvironment: () => diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index ca39484373e9..98d83a6d1d13 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -95,6 +95,7 @@ function makeEnvironmentLinks( overrides: Partial = {}, ): EnvironmentLinks.EnvironmentLinks["Service"] { return { + updateLabel: () => Effect.void, upsert: () => Effect.void, listUsersForEnvironment: () => Effect.succeed(["dev:julius"]), listDeliveryUsersForEnvironment: () => diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index 7f536bafb375..6a4b81391566 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -208,6 +208,7 @@ function makeLinks( overrides: Partial = {}, ): EnvironmentLinks.EnvironmentLinks["Service"] { return { + updateLabel: () => Effect.void, upsert: () => Effect.void, listUsersForEnvironment: () => Effect.succeed([]), listDeliveryUsersForEnvironment: () => Effect.succeed([]), diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index c0811e82d923..1e51ebf6d82e 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -122,6 +122,7 @@ function testLayer(input?: { pruneExpired: Effect.void, }), Layer.succeed(EnvironmentLinks.EnvironmentLinks, { + updateLabel: () => Effect.void, upsert: input?.upsert ?? (() => Effect.void), listUsersForEnvironment: () => Effect.succeed([]), listDeliveryUsersForEnvironment: () => Effect.succeed([]), diff --git a/infra/relay/src/environments/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts index dccb9e39f60f..a1500b99314d 100644 --- a/infra/relay/src/environments/EnvironmentLinks.test.ts +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -8,6 +8,48 @@ import { relayEnvironmentLinks } from "../persistence/schema.ts"; import * as EnvironmentLinks from "./EnvironmentLinks.ts"; describe("EnvironmentLinks", () => { + it.effect("updates only the active link with matching environment credentials", () => { + const updateValues: Array> = []; + const whereConditions: Array = []; + const fakeDb = { + update: (table: unknown) => { + expect(table).toBe(relayEnvironmentLinks); + return { + set: (values: Record) => { + updateValues.push(values); + return { + where: (condition: unknown) => { + whereConditions.push(condition); + return Effect.void; + }, + }; + }, + }; + }, + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const links = yield* EnvironmentLinks.EnvironmentLinks; + yield* links.updateLabel({ + environmentId: "env-1", + environmentPublicKey: "public-key-1", + label: "Build server", + }); + + expect(updateValues[0]?.environmentLabel).toBe("Build server"); + expect(typeof updateValues[0]?.updatedAt).toBe("string"); + const query = new PgDialect().sqlToQuery(whereConditions[0] as never); + expect(query.sql).toContain('"relay_environment_links"."environment_id" = $1'); + expect(query.sql).toContain('"relay_environment_links"."environment_public_key" = $2'); + expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); + expect(query.params).toEqual(["env-1", "public-key-1"]); + }).pipe( + Effect.provide( + EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); + }); + it.effect("retains link lookup failures with user and environment identity", () => { const cause = new Error("database unavailable"); const fakeDb = { diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts index 6630af0a11bf..948e823e1015 100644 --- a/infra/relay/src/environments/EnvironmentLinks.ts +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -101,6 +101,18 @@ export class EnvironmentLinkRevokePersistenceError extends Schema.TaggedErrorCla } } +export class EnvironmentLabelUpdatePersistenceError extends Schema.TaggedErrorClass()( + "EnvironmentLabelUpdatePersistenceError", + { + environmentId: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to update the label for environment '${this.environmentId}'`; + } +} + export class EnvironmentLinks extends Context.Service< EnvironmentLinks, { @@ -137,6 +149,11 @@ export class EnvironmentLinks extends Context.Service< readonly userId: string; readonly environmentId: string; }) => Effect.Effect; + readonly updateLabel: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + readonly label: string; + }) => Effect.Effect; } >()("t3code-relay/environments/EnvironmentLinks") {} @@ -165,6 +182,28 @@ const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return EnvironmentLinks.of({ + updateLabel: Effect.fn("relay.environment_links.update_label")(function* (input) { + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* db + .update(relayEnvironmentLinks) + .set({ environmentLabel: input.label, updatedAt }) + .where( + and( + eq(relayEnvironmentLinks.environmentId, input.environmentId), + eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey), + isNull(relayEnvironmentLinks.revokedAt), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentLabelUpdatePersistenceError({ + environmentId: input.environmentId, + cause, + }), + ), + ); + }), upsert: Effect.fn("relay.environment_links.upsert")(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.proof.environmentId, diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index daf756a2b7cc..5829cd0b6321 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -179,6 +179,7 @@ function relayUnlinkTestLayer(input?: { Layer.succeed( EnvironmentLinks.EnvironmentLinks, EnvironmentLinks.EnvironmentLinks.of({ + updateLabel: () => Effect.die("unused updateLabel"), upsert: () => Effect.die("unused upsert"), listUsersForEnvironment: () => Effect.die("unused listUsersForEnvironment"), listDeliveryUsersForEnvironment: () => Effect.die("unused listDeliveryUsersForEnvironment"), diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 50bcff665a9b..5b8a81b2624d 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -856,7 +856,8 @@ export const serverApi = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; const publishSignatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; - return handlers.handle( + const links = yield* EnvironmentLinks.EnvironmentLinks; + const publishHandlers = handlers.handle( "publishAgentActivity", Effect.fn("relay.api.server.publishAgentActivity")( function* (args) { @@ -984,6 +985,22 @@ export const serverApi = HttpApiBuilder.group( mapRelayCommonApiErrors("not_authorized"), ), ); + return publishHandlers.handle( + "updateEnvironmentLabel", + Effect.fn("relay.api.server.updateEnvironmentLabel")(function* (args) { + const { params, payload } = args; + const principal = yield* RelayEnvironmentPrincipal; + if (principal.environmentId !== params.environmentId) { + return yield* new HttpApiError.Unauthorized({}); + } + yield* links.updateLabel({ + environmentId: params.environmentId, + environmentPublicKey: principal.environmentPublicKey, + label: payload.label, + }); + return { ok: true as const }; + }, mapRelayCommonApiErrors("not_authorized")), + ); }), ); @@ -1015,6 +1032,7 @@ const RelayCommonPersistenceError = Schema.Union([ EnvironmentLinks.EnvironmentLinkListPersistenceError, EnvironmentLinks.EnvironmentLinkLookupPersistenceError, EnvironmentLinks.EnvironmentLinkRevokePersistenceError, + EnvironmentLinks.EnvironmentLabelUpdatePersistenceError, ManagedEndpointAllocations.ManagedEndpointAllocationPersistenceError, EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError, EnvironmentCredentials.EnvironmentCredentialRevokePersistenceError, diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 8edecae5646e..8ac0215e2772 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -41,6 +41,7 @@ import { serverUpdateStateForProgressEvent, serverUpdateStateForServerVersion, validateServerUpdateReadyEvent, + verifyEnvironmentLabelUpdate, } from "./server.ts"; const CONFIG = { @@ -127,6 +128,39 @@ describe("update restart reconnect nudges", () => { }); describe("server state projection", () => { + it.effect("detects servers that ignore environment label updates", () => + Effect.gen(function* () { + yield* verifyEnvironmentLabelUpdate("Build server", { environmentLabel: "Build server" }); + const error = yield* Effect.flip( + verifyEnvironmentLabelUpdate("Build server", { environmentLabel: "" }), + ); + expect(error.message).toBe("This environment server does not support renaming."); + }), + ); + + it("projects environment label updates into the server descriptor", () => { + const current = applyServerConfigProjection( + Option.none(), + snapshotEvent({ + ...CONFIG, + environment: { + environmentId: EnvironmentId.make("environment-1"), + label: "Before", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.33", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + const next = applyServerConfigProjection(current, { + version: 1, + type: "environmentLabelUpdated", + payload: { label: "After" }, + }); + + expect(Option.getOrThrow(next).config.environment.label).toBe("After"); + }); + it("only treats a legacy transport interruption as an unacknowledged handoff", () => { expect(isLegacyUpdateHandoffLoss(Cause.interrupt(1))).toBe(true); expect( diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f579453c27fc..f0f991d67362 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -23,6 +23,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { createAtomCommandScheduler, + createEnvironmentCommand, createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, @@ -65,6 +66,26 @@ export interface ServerUpdateTarget { readonly input: EnvironmentRpcInput; } +export class EnvironmentLabelUpdateUnsupportedError extends Schema.TaggedErrorClass()( + "EnvironmentLabelUpdateUnsupportedError", + { + requestedLabel: Schema.String, + }, +) { + override get message(): string { + return "This environment server does not support renaming."; + } +} + +export function verifyEnvironmentLabelUpdate( + requestedLabel: string, + settings: { readonly environmentLabel: string }, +): Effect.Effect { + return settings.environmentLabel === requestedLabel + ? Effect.void + : new EnvironmentLabelUpdateUnsupportedError({ requestedLabel }); +} + const IDLE_SERVER_UPDATE_STATE: ServerUpdateState = { status: "idle" }; const EMPTY_SERVER_UPDATE_STATE_ATOM = Atom.make(IDLE_SERVER_UPDATE_STATE).pipe( Atom.withLabel("environment-data:server:update-state:empty"), @@ -293,6 +314,18 @@ export function applyServerConfigProjection( latestEvent: event, source: "live", })); + case "environmentLabelUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + environment: { + ...projection.config.environment, + label: event.payload.label, + }, + }, + latestEvent: event, + source: "live", + })); } } @@ -510,6 +543,15 @@ export function createServerEnvironmentAtoms( ); const updateStateAtom = (environmentId: EnvironmentId | null) => environmentId === null ? EMPTY_SERVER_UPDATE_STATE_ATOM : updateStateValueAtom(environmentId); + const updateEnvironmentLabel = createEnvironmentCommand(runtime, { + label: "environment-data:server:update-environment-label", + scheduler: configScheduler, + concurrency: configConcurrency, + execute: (environmentLabel: string) => + request(WS_METHODS.serverUpdateSettings, { + patch: { environmentLabel }, + }).pipe(Effect.tap((settings) => verifyEnvironmentLabelUpdate(environmentLabel, settings))), + }); const updateServer = createRuntimeCommand< EnvironmentRegistry | EnvironmentCacheStore | R, E, @@ -756,6 +798,7 @@ export function createServerEnvironmentAtoms( scheduler: configScheduler, concurrency: configConcurrency, }), + updateEnvironmentLabel, signalProcess: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:signal-process", tag: WS_METHODS.serverSignalProcess, diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 52f7d7d43550..2df6fdcae439 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -853,6 +853,11 @@ export const RelayOkResponse = Schema.Struct({ }); export type RelayOkResponse = typeof RelayOkResponse.Type; +export const RelayEnvironmentLabelUpdateRequest = Schema.Struct({ + label: TrimmedNonEmptyString, +}); +export type RelayEnvironmentLabelUpdateRequest = typeof RelayEnvironmentLabelUpdateRequest.Type; + export const RelayPublishResponse = Schema.Struct({ ok: Schema.Boolean, deliveries: Schema.Array(RelayDeliveryResult), @@ -1066,6 +1071,12 @@ export const RelayServerGroup = HttpApiGroup.make("server") error: RelayAgentActivityPublishErrors, }, ).annotate(OpenApi.Summary, "Publish agent activity"), + HttpApiEndpoint.put("updateEnvironmentLabel", "/v1/environments/:environmentId/label", { + params: Schema.Struct({ environmentId: EnvironmentId }), + payload: RelayEnvironmentLabelUpdateRequest, + success: RelayOkResponse, + error: RelayAuthAndInternalErrors, + }).annotate(OpenApi.Summary, "Update an environment label"), ) .annotate(OpenApi.Description, "Environment-authenticated activity publication.") .middleware(RelayEnvironmentAuth); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9791a4f62185..da0f0ea6f072 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -499,6 +499,12 @@ export const ServerConfigSettingsUpdatedPayload = Schema.Struct({ }); export type ServerConfigSettingsUpdatedPayload = typeof ServerConfigSettingsUpdatedPayload.Type; +export const ServerConfigEnvironmentLabelUpdatedPayload = Schema.Struct({ + label: TrimmedNonEmptyString, +}); +export type ServerConfigEnvironmentLabelUpdatedPayload = + typeof ServerConfigEnvironmentLabelUpdatedPayload.Type; + export const ServerConfigStreamSnapshotEvent = Schema.Struct({ version: Schema.Literal(1), type: Schema.Literal("snapshot"), @@ -530,11 +536,20 @@ export const ServerConfigStreamSettingsUpdatedEvent = Schema.Struct({ export type ServerConfigStreamSettingsUpdatedEvent = typeof ServerConfigStreamSettingsUpdatedEvent.Type; +export const ServerConfigStreamEnvironmentLabelUpdatedEvent = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("environmentLabelUpdated"), + payload: ServerConfigEnvironmentLabelUpdatedPayload, +}); +export type ServerConfigStreamEnvironmentLabelUpdatedEvent = + typeof ServerConfigStreamEnvironmentLabelUpdatedEvent.Type; + export const ServerConfigStreamEvent = Schema.Union([ ServerConfigStreamSnapshotEvent, ServerConfigStreamKeybindingsUpdatedEvent, ServerConfigStreamProviderStatusesEvent, ServerConfigStreamSettingsUpdatedEvent, + ServerConfigStreamEnvironmentLabelUpdatedEvent, ]); export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 570157292b54..bbaa58d77cb4 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -33,6 +33,23 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ServerSettings environment label", () => { + it("defaults to automatic naming", () => { + expect(decodeServerSettings({}).environmentLabel).toBe(""); + }); + + it("trims labels and accepts an empty reset", () => { + expect( + decodeServerSettingsPatch({ environmentLabel: " Build server " }).environmentLabel, + ).toBe("Build server"); + expect(decodeServerSettingsPatch({ environmentLabel: " " }).environmentLabel).toBe(""); + }); + + it("rejects labels longer than 40 characters", () => { + expect(() => decodeServerSettingsPatch({ environmentLabel: "x".repeat(41) })).toThrow(); + }); +}); + describe("ClientSettings glass opacity", () => { it("defaults to a readable translucent surface", () => { expect(decodeClientSettings({}).glassOpacity).toBe(80); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 22ce210ed898..41a9d27217e4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -542,6 +542,9 @@ export const BackgroundActivitySettings = Schema.Struct({ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; export const ServerSettings = Schema.Struct({ + environmentLabel: TrimmedString.check(Schema.isMaxLength(40)).pipe( + Schema.withDecodingDefault(Effect.succeed("")), + ), // Legacy token-by-token assistant output. Deliberately a fresh key (was // `enableAssistantStreaming`): decoding drops the old key, so everyone, // including prior opt-ins, resets to the buffered default. @@ -710,6 +713,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ export const ServerSettingsPatch = Schema.Struct({ // Server settings + environmentLabel: Schema.optionalKey(TrimmedString.check(Schema.isMaxLength(40))), enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( From d73d6f94605e29309973e5d9a411cc97fdbcf919 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Mon, 17 Aug 2026 00:32:52 +0200 Subject: [PATCH 2/6] fix(web): align environment name setting --- .../settings/ConnectionsSettings.tsx | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index a60a660897aa..271e7b909c64 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1351,6 +1351,7 @@ function EnvironmentLabelControl({ label, environmentLabels, canRename, + showValue = false, }: { readonly environmentId: EnvironmentId; readonly label: string; @@ -1359,6 +1360,7 @@ function EnvironmentLabelControl({ readonly label: string; }>; readonly canRename: boolean; + readonly showValue?: boolean; }) { const renameEnvironment = useAtomCommand(serverEnvironment.updateEnvironmentLabel, { reportFailure: false, @@ -1367,7 +1369,7 @@ function EnvironmentLabelControl({ const [value, setValue] = useState(label); const [saving, setSaving] = useState(false); - if (!canRename) return null; + if (!canRename && !showValue) return null; const cancel = () => { setValue(label); @@ -1410,19 +1412,24 @@ function EnvironmentLabelControl({ } }; - if (!editing) { + if (!editing || !canRename) { return ( - + <> + {showValue ? {label} : null} + {canRename ? ( + + ) : null} + ); } @@ -3150,7 +3157,7 @@ export function ConnectionsSettings() { {primaryEnvironment && primaryEnvironmentId ? ( } /> @@ -3511,7 +3519,7 @@ export function ConnectionsSettings() { {primaryEnvironment && primaryEnvironmentId ? ( } /> From f560e7e1231c55d312a6093f49fe6738f02073a6 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Mon, 17 Aug 2026 12:11:43 +0200 Subject: [PATCH 3/6] fix(web): align environment rename interactions --- .../settings/ConnectionsSettings.tsx | 135 +++++++++++------- 1 file changed, 86 insertions(+), 49 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 271e7b909c64..377a6e8ad5d5 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -9,7 +9,7 @@ import { XIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; -import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react"; +import { type ReactNode, memo, useCallback, useEffect, useId, useMemo, useState } from "react"; import { AuthAccessReadScope, AuthAccessWriteScope, @@ -1368,6 +1368,11 @@ function EnvironmentLabelControl({ const [editing, setEditing] = useState(false); const [value, setValue] = useState(label); const [saving, setSaving] = useState(false); + const [pendingDuplicateLabel, setPendingDuplicateLabel] = useState(null); + + useEffect(() => { + if (!canRename) setPendingDuplicateLabel(null); + }, [canRename]); if (!canRename && !showValue) return null; @@ -1375,23 +1380,7 @@ function EnvironmentLabelControl({ setValue(label); setEditing(false); }; - const save = async () => { - const nextLabel = value.trim(); - const duplicate = - nextLabel.length > 0 && - environmentLabels.some( - (environment) => - environment.environmentId !== environmentId && environment.label === nextLabel, - ); - if ( - duplicate && - !window.confirm( - `Another environment is already named “${nextLabel}”. Use this name for both environments?`, - ) - ) { - return; - } - + const save = async (nextLabel: string) => { setSaving(true); const result = await renameEnvironment({ environmentId, input: nextLabel }); setSaving(false); @@ -1434,39 +1423,87 @@ function EnvironmentLabelControl({ } return ( -
{ - event.preventDefault(); - void save(); - }} - > - setValue(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Escape") cancel(); + <> + { + event.preventDefault(); + const nextLabel = value.trim(); + const duplicate = + nextLabel.length > 0 && + environmentLabels.some( + (environment) => + environment.environmentId !== environmentId && environment.label === nextLabel, + ); + if (duplicate) { + setPendingDuplicateLabel(nextLabel); + return; + } + void save(nextLabel); }} - /> - - -
+ setValue(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + cancel(); + }} + /> + + + + { + if (!open) setPendingDuplicateLabel(null); + }} + > + + + Use this name twice? + + Another environment is already named “{pendingDuplicateLabel}”. Both environments will + use the same name. + + + + }> + Cancel + + + + + + ); } From f1b37a687e934e552ec915e185f3e597aa42513b Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Mon, 17 Aug 2026 13:29:05 +0200 Subject: [PATCH 4/6] fix: harden environment rename flows Preserve saved labels during mobile URL edits. Prevent startup and relay synchronization races, and align the web rename action. --- apps/mobile/src/connection/onboarding.ts | 2 +- .../connection/ConnectionEnvironmentRow.tsx | 3 +- .../connection/useConnectionController.ts | 4 +- .../SettingsEnvironmentsRouteScreen.tsx | 2 +- .../state/use-remote-environment-registry.ts | 2 +- .../cloud/EnvironmentLabelRelaySync.test.ts | 161 ++++++++++++++++++ .../src/cloud/EnvironmentLabelRelaySync.ts | 82 +++++---- apps/server/src/cloud/http.ts | 8 + apps/server/src/serverRuntimeStartup.test.ts | 53 +++++- apps/server/src/serverRuntimeStartup.ts | 27 +-- .../settings/ConnectionsSettings.tsx | 4 +- .../src/connection/onboarding.test.ts | 74 ++++++++ .../src/connection/onboarding.ts | 7 +- 13 files changed, 371 insertions(+), 58 deletions(-) create mode 100644 apps/server/src/cloud/EnvironmentLabelRelaySync.test.ts diff --git a/apps/mobile/src/connection/onboarding.ts b/apps/mobile/src/connection/onboarding.ts index 60a660cb4b8f..2b1326bedfff 100644 --- a/apps/mobile/src/connection/onboarding.ts +++ b/apps/mobile/src/connection/onboarding.ts @@ -29,7 +29,7 @@ export const updateBearerConnection = createRuntimeCommand(connectionAtomRuntime }, execute: (input: { readonly environmentId: EnvironmentId; - readonly label: string; + readonly label?: string; readonly httpBaseUrl: string; }) => ConnectionOnboarding.pipe(Effect.flatMap((onboarding) => onboarding.updateBearer(input))), }); diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 3ee47722e94a..1600830b7ae7 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -33,7 +33,7 @@ export function ConnectionEnvironmentRow(props: { readonly onRename: (environmentId: EnvironmentId) => void; readonly onUpdate: ( environmentId: EnvironmentId, - updates: { readonly label: string; readonly displayUrl: string }, + updates: { readonly label?: string; readonly displayUrl: string }, ) => Promise>; }) { const [url, setUrl] = useState(props.environment.displayUrl); @@ -56,7 +56,6 @@ export function ConnectionEnvironmentRow(props: { ); const handleSave = useCallback(async () => { const result = await props.onUpdate(props.environment.environmentId, { - label: "", displayUrl: url.trim(), }); if (AsyncResult.isSuccess(result)) { diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts index bad6b6f17209..bfea3c5a0336 100644 --- a/apps/mobile/src/features/connection/useConnectionController.ts +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -95,11 +95,11 @@ export function useConnectionController() { const updateEnvironment = useCallback( ( environmentId: EnvironmentId, - updates: { readonly label: string; readonly displayUrl: string }, + updates: { readonly label?: string; readonly displayUrl: string }, ) => updateBearer({ environmentId, - label: updates.label, + ...(updates.label === undefined ? {} : { label: updates.label }), httpBaseUrl: updates.displayUrl, }), [updateBearer], diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 9c090b2ed487..d6ac0b9e5a95 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -60,7 +60,7 @@ export function SettingsEnvironmentsRouteScreen() { const handleUpdateEnvironment = useCallback( ( environmentId: EnvironmentId, - updates: { readonly label: string; readonly displayUrl: string }, + updates: { readonly label?: string; readonly displayUrl: string }, ) => { if (!SHOWCASE_ENABLED) return onUpdateEnvironment(environmentId, updates); const actualEnvironment = environmentSections.localEnvironments.find( diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 6fb41fc091f1..fd1b9e85b347 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -186,7 +186,7 @@ export function useRemoteConnections() { const onUpdateEnvironment = useCallback( ( environmentId: EnvironmentId, - updates: { readonly label: string; readonly displayUrl: string }, + updates: { readonly label?: string; readonly displayUrl: string }, ) => controller.updateEnvironment(environmentId, updates), [controller], ); diff --git a/apps/server/src/cloud/EnvironmentLabelRelaySync.test.ts b/apps/server/src/cloud/EnvironmentLabelRelaySync.test.ts new file mode 100644 index 000000000000..de14ca22abd9 --- /dev/null +++ b/apps/server/src/cloud/EnvironmentLabelRelaySync.test.ts @@ -0,0 +1,161 @@ +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + type ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_URL_SECRET } from "./config.ts"; +import { + runEnvironmentLabelRelaySync, + synchronizeCurrentEnvironmentLabelWithRelay, +} from "./EnvironmentLabelRelaySync.ts"; + +const environmentId = EnvironmentId.make("environment-test"); +const encode = (value: string) => new TextEncoder().encode(value); + +function makeSecretStore() { + const values = new Map([ + [RELAY_URL_SECRET, encode("https://relay.example.test")], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, encode("relay-credential")], + ]); + return ServerSecretStore.ServerSecretStore.of({ + get: (name) => Effect.succeed(Option.fromUndefinedOr(values.get(name))), + set: () => Effect.die("unused"), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + }); +} + +function descriptor(label: string): ExecutionEnvironmentDescriptor { + return { + environmentId, + label, + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }; +} + +function response(request: HttpClientRequest.HttpClientRequest) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); +} + +function requestLabel(request: HttpClientRequest.HttpClientRequest): string { + assert.equal(request.body._tag, "Uint8Array"); + if (request.body._tag !== "Uint8Array") return ""; + return (JSON.parse(new TextDecoder().decode(request.body.body)) as { readonly label: string }) + .label; +} + +it.effect("synchronizes the current descriptor label with the relay", () => + Effect.gen(function* () { + const requests: Array<{ readonly label: string; readonly authorization: string | undefined }> = + []; + const client = HttpClient.make((request) => + Effect.sync(() => { + requests.push({ + label: requestLabel(request), + authorization: request.headers.authorization, + }); + return response(request); + }), + ); + + yield* synchronizeCurrentEnvironmentLabelWithRelay().pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, makeSecretStore()), + Effect.provideService(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor("Current label")), + setEnvironmentLabel: () => Effect.void, + }), + Effect.provideService(HttpClient.HttpClient, client), + ); + + assert.deepStrictEqual(requests, [ + { label: "Current label", authorization: "Bearer relay-credential" }, + ]); + }), +); + +it.effect("cancels an older synchronization when a newer label arrives", () => + Effect.scoped( + Effect.gen(function* () { + const changes = yield* PubSub.unbounded(); + const currentLabel = yield* Ref.make(""); + const requestLabels = yield* Ref.make>([]); + const oldRequestStarted = yield* Deferred.make(); + const newRequestCompleted = yield* Deferred.make(); + const releaseOldRequest = yield* Deferred.make(); + const client = HttpClient.make((request) => { + const label = requestLabel(request); + const record = Ref.update(requestLabels, (labels) => [...labels, label]); + if (label === "Old label") { + return record.pipe( + Effect.andThen(Deferred.succeed(oldRequestStarted, undefined)), + Effect.andThen(Deferred.await(releaseOldRequest)), + Effect.as(response(request)), + ); + } + return record.pipe( + Effect.andThen(Deferred.succeed(newRequestCompleted, undefined)), + Effect.as(response(request)), + ); + }); + const settings = ServerSettings.ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.succeed({ ...DEFAULT_SERVER_SETTINGS, environmentLabel: "Old label" }), + updateSettings: () => Effect.die("unused"), + streamChanges: Stream.empty, + subscribeChanges: PubSub.subscribe(changes).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + }); + const environment = ServerEnvironment.ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Ref.get(currentLabel).pipe(Effect.map(descriptor)), + setEnvironmentLabel: (label) => Ref.set(currentLabel, label), + }); + + const fiber = yield* runEnvironmentLabelRelaySync().pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, makeSecretStore()), + Effect.provideService(ServerSettings.ServerSettingsService, settings), + Effect.provideService(ServerEnvironment.ServerEnvironment, environment), + Effect.provideService(HttpClient.HttpClient, client), + Effect.forkScoped, + ); + + yield* Deferred.await(oldRequestStarted); + yield* PubSub.publish(changes, { + ...DEFAULT_SERVER_SETTINGS, + environmentLabel: "New label", + }); + yield* Deferred.await(newRequestCompleted); + + assert.deepStrictEqual(yield* Ref.get(requestLabels), ["Old label", "New label"]); + assert.equal(yield* Ref.get(currentLabel), "New label"); + yield* Fiber.interrupt(fiber); + }), + ), +); diff --git a/apps/server/src/cloud/EnvironmentLabelRelaySync.ts b/apps/server/src/cloud/EnvironmentLabelRelaySync.ts index 8bdd6b1c081f..2f23f60cb1bf 100644 --- a/apps/server/src/cloud/EnvironmentLabelRelaySync.ts +++ b/apps/server/src/cloud/EnvironmentLabelRelaySync.ts @@ -4,7 +4,6 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; -import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -20,20 +19,44 @@ const retrySchedule = Schedule.exponential("1 second").pipe( Schedule.upTo({ duration: "10 minutes" }), ); -export const runEnvironmentLabelRelaySync = Effect.fn("runEnvironmentLabelRelaySync")(function* () { +const readSecretString = (secrets: ServerSecretStore.ServerSecretStore["Service"], name: string) => + secrets.get(name).pipe( + Effect.map( + Option.match({ + onNone: () => null, + onSome: (bytes) => new TextDecoder().decode(bytes), + }), + ), + ); + +export const synchronizeCurrentEnvironmentLabelWithRelay = Effect.fn( + "synchronizeCurrentEnvironmentLabelWithRelay", +)(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; const environment = yield* ServerEnvironment.ServerEnvironment; - const settings = yield* ServerSettings.ServerSettingsService; + const [relayUrl, environmentCredential] = yield* Effect.all([ + readSecretString(secrets, RELAY_URL_SECRET), + readSecretString(secrets, RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + if (!relayUrl || !environmentCredential) return; - const readSecretString = (name: string) => - secrets.get(name).pipe( - Effect.map( - Option.match({ - onNone: () => null, - onSome: (bytes) => new TextDecoder().decode(bytes), - }), - ), - ); + const descriptor = yield* environment.getDescriptor; + const client = yield* HttpApiClient.make(RelayApi, { + baseUrl: relayUrl, + transformClient: relayEnvironmentClient(environmentCredential), + }); + yield* client.server.updateEnvironmentLabel({ + params: { environmentId: descriptor.environmentId }, + payload: { label: descriptor.label }, + }); + yield* Effect.logDebug("synchronized environment label with relay", { + environmentId: descriptor.environmentId, + }); +}); + +export const runEnvironmentLabelRelaySync = Effect.fn("runEnvironmentLabelRelaySync")(function* () { + const environment = yield* ServerEnvironment.ServerEnvironment; + const settings = yield* ServerSettings.ServerSettingsService; const synchronize = Effect.fn("synchronizeEnvironmentLabel")(function* ( environmentLabel: string, @@ -42,24 +65,7 @@ export const runEnvironmentLabelRelaySync = Effect.fn("runEnvironmentLabelRelayS // general descriptor watcher runs independently and may not have seen // this settings event yet. yield* environment.setEnvironmentLabel(environmentLabel); - const [relayUrl, environmentCredential] = yield* Effect.all([ - readSecretString(RELAY_URL_SECRET), - readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), - ]); - if (!relayUrl || !environmentCredential) return; - - const descriptor = yield* environment.getDescriptor; - const client = yield* HttpApiClient.make(RelayApi, { - baseUrl: relayUrl, - transformClient: relayEnvironmentClient(environmentCredential), - }).pipe(Effect.provide(FetchHttpClient.layer)); - yield* client.server.updateEnvironmentLabel({ - params: { environmentId: descriptor.environmentId }, - payload: { label: descriptor.label }, - }); - yield* Effect.logDebug("synchronized environment label with relay", { - environmentId: descriptor.environmentId, - }); + yield* synchronizeCurrentEnvironmentLabelWithRelay(); }); const changes = yield* settings.subscribeChanges; @@ -72,10 +78,16 @@ export const runEnvironmentLabelRelaySync = Effect.fn("runEnvironmentLabelRelayS ), ); - yield* synchronizeWithRetry(initialSettings.environmentLabel); - yield* changes.pipe( - Stream.map((next) => next.environmentLabel), - Stream.changes, - Stream.runForEach(synchronizeWithRetry), + yield* Stream.concat( + Stream.make(initialSettings.environmentLabel), + changes.pipe( + Stream.map((next) => next.environmentLabel), + Stream.changes, + ), + ).pipe( + Stream.switchMap((environmentLabel) => + Stream.fromEffect(synchronizeWithRetry(environmentLabel)), + ), + Stream.runDrain, ); }); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 5c744bbfb9d8..9fb54361773f 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -76,6 +76,7 @@ import { RELAY_ISSUER_SECRET, RELAY_URL_SECRET, } from "./config.ts"; +import { synchronizeCurrentEnvironmentLabelWithRelay } from "./EnvironmentLabelRelaySync.ts"; import { relayUrlConfig } from "./publicConfig.ts"; import { readCliDesiredCloudLink, @@ -492,6 +493,13 @@ const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(fu } else { yield* dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); } + yield* synchronizeCurrentEnvironmentLabelWithRelay().pipe( + Effect.catch((cause) => + Effect.logWarning("failed to synchronize environment label after relay configuration", { + cause, + }), + ), + ); return { ok, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; }); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index e3f7e482b2e0..d71747b7accd 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,5 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_MODEL, + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -7,14 +14,18 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as ServerSettings from "./serverSettings.ts"; it("uses the canonical Codex default for auto-bootstrapped model selection", () => { assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), { @@ -70,6 +81,46 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => ), ); +it.effect("environment label updates subscribe before reading the initial snapshot", () => + Effect.scoped( + Effect.gen(function* () { + const changes = yield* PubSub.unbounded(); + const appliedLabels = yield* Ref.make>([]); + const freshLabelApplied = yield* Deferred.make(); + const staleSettings = { ...DEFAULT_SERVER_SETTINGS, environmentLabel: "stale" }; + const freshSettings = { ...DEFAULT_SERVER_SETTINGS, environmentLabel: "fresh" }; + const scope = yield* Scope.Scope; + + const fiber = yield* ServerRuntimeStartup.runEnvironmentLabelUpdates(scope).pipe( + Effect.provideService(ServerSettings.ServerSettingsService, { + start: Effect.void, + ready: Effect.void, + getSettings: PubSub.publish(changes, freshSettings).pipe(Effect.as(staleSettings)), + updateSettings: () => Effect.die("unused"), + streamChanges: Stream.empty, + subscribeChanges: PubSub.subscribe(changes).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + }), + Effect.provideService(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-test")), + getDescriptor: Effect.die("unused"), + setEnvironmentLabel: (label) => + Ref.update(appliedLabels, (labels) => [...labels, label]).pipe( + Effect.andThen( + label === "fresh" ? Deferred.succeed(freshLabelApplied, undefined) : Effect.void, + ), + ), + }), + ); + + yield* Deferred.await(freshLabelApplied); + assert.deepStrictEqual(yield* Ref.get(appliedLabels), ["stale", "fresh"]); + yield* Fiber.interrupt(fiber); + }), + ), +); + it.effect("launchStartupHeartbeat does not block the caller while counts are loading", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 9a87563b097d..f1b68b7a6756 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -45,6 +45,22 @@ import { issueHeadlessServeAccessInfo, } from "./startupAccess.ts"; +export const runEnvironmentLabelUpdates = Effect.fn("runEnvironmentLabelUpdates")(function* ( + scope: Scope.Scope, +) { + const settings = yield* ServerSettings.ServerSettingsService; + const environment = yield* ServerEnvironment.ServerEnvironment; + const changes = yield* settings.subscribeChanges.pipe(Scope.provide(scope)); + const initialSettings = yield* settings.getSettings; + + yield* environment.setEnvironmentLabel(initialSettings.environmentLabel); + return yield* changes.pipe( + Stream.runForEach((next) => environment.setEnvironmentLabel(next.environmentLabel)), + Scope.provide(scope), + Effect.forkIn(scope), + ); +}); + export class ServerRuntimeStartupError extends Schema.TaggedErrorClass()( "ServerRuntimeStartupError", { @@ -357,16 +373,7 @@ export const make = (options?: StartupOptions) => ); const welcomeBase = yield* resolveWelcomeBase; - yield* serverEnvironment.setEnvironmentLabel( - (yield* serverSettings.getSettings).environmentLabel, - ); - yield* serverSettings.streamChanges.pipe( - Stream.runForEach((settings) => - serverEnvironment.setEnvironmentLabel(settings.environmentLabel), - ), - Scope.provide(reactorScope), - Effect.forkScoped, - ); + yield* runEnvironmentLabelUpdates(reactorScope); yield* runEnvironmentLabelRelaySync().pipe(Scope.provide(reactorScope), Effect.forkScoped); const environment = yield* serverEnvironment.getDescriptor; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 377a6e8ad5d5..1c2f50441a68 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1407,8 +1407,8 @@ function EnvironmentLabelControl({ {showValue ? {label} : null} {canRename ? (