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/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 03a0eb5025f6..1600830b7ae7 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 }, + 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,15 @@ 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(), displayUrl: url.trim(), }); if (AsyncResult.isSuccess(result)) { @@ -60,7 +67,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 +121,30 @@ export function ConnectionEnvironmentRow(props: { ) : null} - + + {canRename ? ( + { + event.stopPropagation(); + props.onRename(props.environment.environmentId); + }} + > + + + ) : null} + + {props.expanded ? ( @@ -137,20 +159,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/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/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..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( @@ -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/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 new file mode 100644 index 000000000000..2f23f60cb1bf --- /dev/null +++ b/apps/server/src/cloud/EnvironmentLabelRelaySync.ts @@ -0,0 +1,93 @@ +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 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" }), +); + +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 [relayUrl, environmentCredential] = yield* Effect.all([ + readSecretString(secrets, RELAY_URL_SECRET), + readSecretString(secrets, RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + if (!relayUrl || !environmentCredential) return; + + 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, + ) { + // 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); + yield* synchronizeCurrentEnvironmentLabelWithRelay(); + }); + + 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* 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.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/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/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.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 5db2b75556ee..f1b68b7a6756 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"; @@ -43,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", { @@ -355,6 +373,9 @@ export const make = (options?: StartupOptions) => ); const welcomeBase = yield* resolveWelcomeBase; + yield* runEnvironmentLabelUpdates(reactorScope); + 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..2d77558d8dbf 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,12 +1,15 @@ 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"; +import { type ReactNode, memo, useCallback, useEffect, useId, useMemo, useState } from "react"; import { AuthAccessReadScope, AuthAccessWriteScope, @@ -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,183 @@ 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, + showValue = false, + valueClassName = "text-[13px] text-muted-foreground", + valueElement: ValueElement = "span", +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly environmentLabels: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly label: string; + }>; + readonly canRename: boolean; + readonly showValue?: boolean; + readonly valueClassName?: string; + readonly valueElement?: "span" | "h3"; +}) { + const renameEnvironment = useAtomCommand(serverEnvironment.updateEnvironmentLabel, { + reportFailure: false, + }); + 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; + + const cancel = () => { + setValue(label); + setEditing(false); + }; + const save = async (nextLabel: string) => { + 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 || !canRename) { + return ( + <> + {showValue ? {label} : null} + {canRename ? ( + + ) : null} + + ); + } + + return ( + <> +
{ + 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 + + + + + + + ); +} + function SavedBackendListRow({ environment, + environmentLabels, removingEnvironmentId, onConnect, onRemove, @@ -1347,6 +1521,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" @@ -1418,7 +1599,15 @@ function SavedBackendListRow({ : null } /> -

{environment.label}

+ {metadataBits.length > 0 ? (

{metadataBits.join(" · ")}

@@ -1863,6 +2052,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 +3198,21 @@ 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/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 9bee0dad6fb0..9bcaa799895f 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -206,6 +206,80 @@ describe("connection onboarding", () => { }), ); + it.effect("preserves the saved label when updating only the bearer URL", () => + Effect.gen(function* () { + const environmentId = EnvironmentId.make("environment-paired"); + const registration = yield* prepareBearerConnectionUpdate({ + input: { + environmentId, + httpBaseUrl: "http://new.example.test/path", + }, + entry: Option.some({ + target: new BearerConnectionTarget({ + environmentId, + label: "Saved label", + connectionId: "bearer:environment-paired", + }), + profile: Option.some( + new BearerConnectionProfile({ + connectionId: "bearer:environment-paired", + environmentId, + label: "Saved label", + httpBaseUrl: "http://old.example.test/", + wsBaseUrl: "ws://old.example.test/", + }), + ), + }), + credential: Option.some(new BearerConnectionCredential({ token: "bearer-token" })), + }); + + expect(registration).toMatchObject({ + target: { label: "Saved label" }, + profile: { + label: "Saved label", + httpBaseUrl: "http://new.example.test/", + wsBaseUrl: "ws://new.example.test/", + }, + }); + }), + ); + + it.effect("rejects an explicitly empty bearer label", () => + Effect.gen(function* () { + const environmentId = EnvironmentId.make("environment-paired"); + const error = yield* prepareBearerConnectionUpdate({ + input: { + environmentId, + label: " ", + httpBaseUrl: "http://new.example.test/", + }, + entry: Option.some({ + target: new BearerConnectionTarget({ + environmentId, + label: "Saved label", + connectionId: "bearer:environment-paired", + }), + profile: Option.some( + new BearerConnectionProfile({ + connectionId: "bearer:environment-paired", + environmentId, + label: "Saved label", + httpBaseUrl: "http://old.example.test/", + wsBaseUrl: "ws://old.example.test/", + }), + ), + }), + credential: Option.some(new BearerConnectionCredential({ token: "bearer-token" })), + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ConnectionBlockedError", + reason: "configuration", + message: "Environment label cannot be empty.", + }); + }), + ); + it.effect("prepares an SSH registration from the provisioned platform environment", () => Effect.gen(function* () { const target = { diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index e76bcd50a2cc..8b3c455cac39 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -45,7 +45,7 @@ export interface SshConnectionInput { export interface BearerConnectionUpdateInput { readonly environmentId: EnvironmentId; - readonly label: string; + readonly label?: string; readonly httpBaseUrl: string; } @@ -177,13 +177,14 @@ export const prepareBearerConnectionUpdate = Effect.fn( }); } - const label = options.input.label.trim(); - if (label === "") { + const submittedLabel = options.input.label?.trim(); + if (submittedLabel === "") { return yield* new ConnectionBlockedError({ reason: "configuration", detail: "Environment label cannot be empty.", }); } + const label = submittedLabel ?? entry.profile.value.label; const httpBaseUrl = yield* Effect.try({ try: () => normalizeHttpBaseUrl(options.input.httpBaseUrl), catch: (cause) => 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(