diff --git a/apps/web/src/components/settings/RenameEnvironmentDialog.tsx b/apps/web/src/components/settings/RenameEnvironmentDialog.tsx
new file mode 100644
index 0000000000000..78e33725f15d6
--- /dev/null
+++ b/apps/web/src/components/settings/RenameEnvironmentDialog.tsx
@@ -0,0 +1,111 @@
+import {
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+import type { EnvironmentId } from "@t3tools/contracts";
+import { useState } from "react";
+
+import { environmentCatalog } from "~/connection/catalog";
+import { useAtomCommand } from "~/state/use-atom-command";
+import { Button } from "../ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogFooter,
+ DialogHeader,
+ DialogPanel,
+ DialogPopup,
+ DialogTitle,
+} from "../ui/dialog";
+import { Input } from "../ui/input";
+import { Label } from "../ui/label";
+import { stackedThreadToast, toastManager } from "../ui/toast";
+
+// The popup unmounts when closed, so each open starts from the current label.
+function RenameEnvironmentForm({
+ environmentId,
+ label: currentLabel,
+ onDone,
+}: {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+ readonly onDone: () => void;
+}) {
+ const [label, setLabel] = useState(currentLabel);
+ const setEnvironmentLabel = useAtomCommand(environmentCatalog.setLabel, "Rename environment");
+ const canSave = label.trim() !== "";
+
+ const save = async () => {
+ if (!canSave) return;
+ const result = await setEnvironmentLabel({ environmentId, label });
+ if (result._tag === "Success") {
+ onDone();
+ return;
+ }
+ if (isAtomCommandInterrupted(result)) return;
+ const error = squashAtomCommandFailure(result);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Could not rename environment",
+ description: error instanceof Error ? error.message : "The label was not saved.",
+ }),
+ );
+ };
+
+ return (
+ <>
+
+ Rename environment
+
+
+
+
+
+ }>Cancel
+
+
+ >
+ );
+}
+
+export function RenameEnvironmentDialog({
+ open,
+ onOpenChange,
+ environmentId,
+ label,
+}: {
+ readonly open: boolean;
+ readonly onOpenChange: (open: boolean) => void;
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx
index 53b745412d983..8029d8e988486 100644
--- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx
+++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx
@@ -3,6 +3,7 @@ import {
ChevronRightIcon,
ChevronsLeftRightEllipsisIcon,
EllipsisIcon,
+ PencilIcon,
RouteIcon,
} from "lucide-react";
import { useAtomValue } from "@effect/atom-react";
@@ -29,6 +30,7 @@ import { SettingsRow } from "./settingsLayout";
import { searchableSetting } from "./settingsSearch";
import { EnvironmentIconMenu } from "./EnvironmentIconPicker";
import { EnvironmentRoutesList } from "./EnvironmentRoutesList";
+import { RenameEnvironmentDialog } from "./RenameEnvironmentDialog";
import {
ConnectionDiagnosisPanel,
ConnectionDiagnosisSummary,
@@ -117,6 +119,7 @@ export function SavedBackendListRow({
const [routesOpen, setRoutesOpen] = useState(false);
const [permissionsOpen, setPermissionsOpen] = useState(false);
const [diagnosisOpen, setDiagnosisOpen] = useState(false);
+ const [renameOpen, setRenameOpen] = useState(false);
const environmentId = environment.environmentId;
const unsupported = environment.connection.phase === "unsupported";
const blocked = unsupported && !hasSavedBearerRoute(environment.entry);
@@ -413,6 +416,12 @@ export function SavedBackendListRow({
environmentId={environmentId}
serverConfig={environment.serverConfig}
/>
+ {environment.relayManaged ? null : (
+
+ )}
+
);
}
diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts
index f6c305ab6bb90..55c1663b2d312 100644
--- a/packages/client-runtime/src/connection/registry.test.ts
+++ b/packages/client-runtime/src/connection/registry.test.ts
@@ -2196,6 +2196,107 @@ describe("EnvironmentRegistry routes", () => {
}),
);
+ it.effect("renames every route and keeps credentials and learned state", () =>
+ Effect.gen(function* () {
+ const environmentId = LAN_TARGET.environmentId;
+ const learnedId = `learned:${environmentId}:http://100.64.0.9:3773@${LAN_TARGET.connectionId}`;
+ const connectedProfileLabels: Array = [];
+ const harness = yield* makeHarness(
+ [
+ new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId }),
+ LAN_TARGET,
+ RELAY_TARGET,
+ ],
+ [
+ new BearerConnectionProfile({ ...LAN_PROFILE, connectionId: learnedId, learned: true }),
+ LAN_PROFILE,
+ ],
+ [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]],
+ {
+ checkRoute: (route) => {
+ if (Option.isSome(route.profile))
+ connectedProfileLabels.push(route.profile.value.label);
+ return "unchecked";
+ },
+ },
+ );
+ yield* Effect.gen(function* () {
+ const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
+ yield* awaitConnectionState(
+ registry,
+ environmentId,
+ (state) => state.phase === "connected",
+ );
+ yield* registry.setLabel(environmentId, " Studio ");
+
+ expect((yield* registry.state(environmentId)).phase).toBe("connected");
+ expect(yield* Ref.get(harness.sessions)).toHaveLength(1);
+ expect(yield* Ref.get(harness.releasedSessions)).toBe(0);
+ expect(
+ routesOf(yield* Ref.get(harness.storedTargets), environmentId).map((target) => [
+ target._tag,
+ target.label,
+ ]),
+ ).toEqual([
+ ["BearerConnectionTarget", "Studio"],
+ ["BearerConnectionTarget", "Studio"],
+ ["RelayConnectionTarget", "Studio"],
+ ]);
+ const profiles = yield* Ref.get(harness.storedProfiles);
+ expect(profiles.get(learnedId)).toMatchObject({ label: "Studio", learned: true });
+ expect(profiles.get(LAN_TARGET.connectionId)?.label).toBe("Studio");
+ expect((yield* Ref.get(harness.storedCredentials)).get(LAN_TARGET.connectionId)).toEqual(
+ BEARER_CREDENTIAL,
+ );
+ const entry = (yield* SubscriptionRef.get(registry.entries)).get(environmentId);
+ expect(entry?.target.label).toBe("Studio");
+
+ // A retained supervisor must reconnect with the new label: the SSH broker saves the
+ // profile it is handed, so a stale one would write the old label back.
+ connectedProfileLabels.length = 0;
+ yield* registry.setEnabled(environmentId, false);
+ yield* awaitConnectionState(
+ registry,
+ environmentId,
+ (state) => state.phase !== "connected",
+ );
+ yield* registry.setEnabled(environmentId, true);
+ yield* awaitConnectionState(
+ registry,
+ environmentId,
+ (state) => state.phase === "connected",
+ );
+ expect(connectedProfileLabels).not.toHaveLength(0);
+ expect(new Set(connectedProfileLabels)).toEqual(new Set(["Studio"]));
+
+ const error = yield* registry.setLabel(environmentId, " ").pipe(Effect.flip);
+ expect(error._tag).toBe("ConnectionBlockedError");
+ }).pipe(Effect.provide(harness.layer), Effect.scoped);
+ }),
+ );
+
+ it.effect("a rename keeps the SSH target the broker last saved", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]);
+ yield* Effect.gen(function* () {
+ const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
+ const moved = { ...SSH_TARGET, hostname: "moved.example.test" };
+ yield* Ref.update(harness.storedProfiles, (current) =>
+ new Map(current).set(
+ SSH_CONNECTION.connectionId,
+ new SshConnectionProfile({ ...SSH_PROFILE, target: moved }),
+ ),
+ );
+
+ yield* registry.setLabel(SSH_CONNECTION.environmentId, "Studio");
+
+ expect((yield* Ref.get(harness.storedProfiles)).get(SSH_CONNECTION.connectionId)).toEqual(
+ new SshConnectionProfile({ ...SSH_PROFILE, label: "Studio", target: moved }),
+ );
+ }).pipe(Effect.provide(harness.layer), Effect.scoped);
+ }),
+ );
+
it.effect("removing the last route forgets the environment", () =>
Effect.gen(function* () {
const harness = yield* makeHarness(
@@ -2246,6 +2347,9 @@ describe("EnvironmentRegistry routes", () => {
LAN_TARGET.connectionId,
]);
expect(yield* permissions.get(yield* current())).toBe("read");
+
+ yield* registry.setLabel(LAN_TARGET.environmentId, "Studio");
+ expect(yield* permissions.get(yield* current())).toBe("read");
}).pipe(
Effect.provide(harness.layer),
Effect.provideService(PullRequestRoutingPermissions, permissions),
diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts
index 12f8aa5ec505f..35d44beed339f 100644
--- a/packages/client-runtime/src/connection/registry.ts
+++ b/packages/client-runtime/src/connection/registry.ts
@@ -51,6 +51,8 @@ import {
findRouteToSameAddress,
hasSavedBearerRoute,
mergeLearnedRoutes,
+ profileWithLabel,
+ routeWithLabel,
routesAfterRemoving,
upsertRoute,
type ReportedEndpoint,
@@ -151,6 +153,16 @@ export class EnvironmentRegistry extends Context.Service<
| PlatformEnvironmentRemovalError
| ConnectionBlockedError
>;
+ readonly setLabel: (
+ environmentId: EnvironmentId,
+ label: string,
+ ) => Effect.Effect<
+ void,
+ | Persistence.ConnectionPersistenceError
+ | ConnectionAttemptError
+ | EnvironmentNotRegisteredError
+ | PlatformEnvironmentRemovalError
+ >;
readonly removeRelayEnvironments: () => Effect.Effect<
void,
| Persistence.ConnectionPersistenceError
@@ -202,7 +214,7 @@ export class EnvironmentRegistry extends Context.Service<
interface EnvironmentServiceScope {
readonly entry: ConnectionCatalogEntry;
- readonly supervisor: EnvironmentSupervisor.EnvironmentSupervisor["Service"];
+ readonly supervisor: EnvironmentSupervisor.ManagedEnvironmentSupervisor;
readonly scope: Scope.Closeable;
}
@@ -1025,6 +1037,54 @@ export const make = Effect.gen(function* () {
);
});
+ const setLabel = Effect.fn("EnvironmentRegistry.setLabel")(function* (
+ environmentId: EnvironmentId,
+ label: string,
+ ) {
+ const trimmed = label.trim();
+ if (trimmed === "") {
+ return yield* new ConnectionBlockedError({
+ reason: "configuration",
+ detail: "Environment label cannot be empty.",
+ });
+ }
+ yield* withLeaseLock(
+ environmentId,
+ Effect.gen(function* () {
+ const entry = yield* userEntry(environmentId);
+ const routes = connectionRoutes(entry);
+ if (routes.every((route) => route.target.label === trimmed)) return;
+ // Every route carries the label: the first one is displayed, and reordering can change it.
+ const renamed = routes.map((route) => routeWithLabel(route, trimmed));
+ for (const { profile } of routes) {
+ if (Option.isNone(profile)) continue;
+ // The SSH broker saves a freshly resolved target only to the store, so relabel that copy.
+ const saved = yield* profiles.get(profile.value.connectionId);
+ yield* profiles.put(
+ profileWithLabel(
+ Option.getOrElse(saved, () => profile.value),
+ trimmed,
+ ),
+ );
+ }
+ const next = entryWithRoutes(entry, renamed);
+ yield* registrations.setRoutes(environmentId, persistedRoutes(next));
+ // A label changes no address, so the supervisor keeps its session. Its next connect uses
+ // the new label; its own messages keep the old one until it is next recreated.
+ const lease = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId);
+ if (lease !== undefined) {
+ yield* lease.supervisor.setLabel(trimmed);
+ yield* SubscriptionRef.update(serviceScopes, (current) =>
+ new Map(current).set(environmentId, { ...lease, entry: next }),
+ );
+ }
+ yield* SubscriptionRef.update(entries, (current) =>
+ new Map(current).set(environmentId, next),
+ );
+ }),
+ );
+ });
+
const removeRelayEnvironments = Effect.fn("EnvironmentRegistry.removeRelayEnvironments")(
function* () {
const relayEnvironmentIds = [...(yield* SubscriptionRef.get(entries)).values()]
@@ -1193,6 +1253,7 @@ export const make = Effect.gen(function* () {
remove,
removeRoute,
reorderRoutes,
+ setLabel,
removeRelayEnvironments,
retryNow,
setEnabled,
diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts
index 9d83550a8c4a2..db0d0fbacd98d 100644
--- a/packages/client-runtime/src/connection/routes.ts
+++ b/packages/client-runtime/src/connection/routes.ts
@@ -10,9 +10,17 @@ import * as Option from "effect/Option";
import {
BearerConnectionProfile,
type ConnectionCatalogEntry,
+ type ConnectionProfile,
type ConnectionRoute,
+ SshConnectionProfile,
} from "./catalog.ts";
-import { BearerConnectionTarget, type ConnectionTarget } from "./model.ts";
+import {
+ BearerConnectionTarget,
+ type ConnectionTarget,
+ PrimaryConnectionTarget,
+ RelayConnectionTarget,
+ SshConnectionTarget,
+} from "./model.ts";
export type ConnectionRouteKind =
| "relay"
@@ -65,6 +73,32 @@ export function routeEntry(
return entryWithRoutes(entry, [route]);
}
+function targetWithLabel(target: ConnectionTarget, label: string): ConnectionTarget {
+ switch (target._tag) {
+ case "PrimaryConnectionTarget":
+ return new PrimaryConnectionTarget({ ...target, label });
+ case "BearerConnectionTarget":
+ return new BearerConnectionTarget({ ...target, label });
+ case "RelayConnectionTarget":
+ return new RelayConnectionTarget({ ...target, label });
+ case "SshConnectionTarget":
+ return new SshConnectionTarget({ ...target, label });
+ }
+}
+
+export function routeWithLabel(route: ConnectionRoute, label: string): ConnectionRoute {
+ return {
+ target: targetWithLabel(route.target, label),
+ profile: Option.map(route.profile, (profile) => profileWithLabel(profile, label)),
+ };
+}
+
+export function profileWithLabel(profile: ConnectionProfile, label: string): ConnectionProfile {
+ return profile._tag === "SshConnectionProfile"
+ ? new SshConnectionProfile({ ...profile, label })
+ : new BearerConnectionProfile({ ...profile, label });
+}
+
export function routeHttpBaseUrl(route: ConnectionRoute): string | null {
if (route.target._tag === "PrimaryConnectionTarget") return route.target.httpBaseUrl;
const profile = Option.getOrNull(route.profile);
diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts
index b4fc6955366a0..b2a696a95f7c4 100644
--- a/packages/client-runtime/src/connection/supervisor.test.ts
+++ b/packages/client-runtime/src/connection/supervisor.test.ts
@@ -2002,4 +2002,40 @@ describe("EnvironmentSupervisor routes", () => {
]);
}),
);
+
+ it.effect("keeps a rename that lands while route learning finishes", () =>
+ Effect.gen(function* () {
+ const learned = yield* Deferred.make();
+ const preparedLabels = yield* Ref.make>([]);
+ const harness = yield* makeHarness({
+ configEvents: Stream.make({
+ version: 1 as const,
+ type: "snapshot" as const,
+ config: { directEndpoints: [] } as unknown as ServerConfig,
+ }).pipe(Stream.ensuring(Deferred.succeed(learned, undefined))),
+ prepare: (_attempt, target) =>
+ Ref.update(preparedLabels, (labels) => [...labels, target.label]).pipe(
+ Effect.as(preparedFor(target)),
+ ),
+ });
+ const entry: ConnectionCatalogEntry = {
+ target: RELAY_TARGET,
+ profile: Option.none(),
+ enabled: true,
+ };
+ const supervisor: EnvironmentSupervisor.ManagedEnvironmentSupervisor =
+ yield* EnvironmentSupervisor.make(entry, {
+ initiallyDesired: true,
+ learnRoutes: () => supervisor.setLabel("Studio").pipe(Effect.as(Option.some(entry))),
+ }).pipe(Effect.provide(harness.dependencies));
+
+ yield* Deferred.await(learned);
+ yield* supervisor.disconnect;
+ yield* awaitState(supervisor.state, (state) => state.phase !== "connected");
+ yield* supervisor.connect;
+ yield* awaitState(supervisor.state, (state) => state.phase === "connected");
+
+ expect((yield* Ref.get(preparedLabels)).at(-1)).toBe("Studio");
+ }),
+ );
});
diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts
index 5f279edf568c4..2c83821eaec44 100644
--- a/packages/client-runtime/src/connection/supervisor.ts
+++ b/packages/client-runtime/src/connection/supervisor.ts
@@ -38,6 +38,7 @@ import {
entryWithRoutes,
insertRoute,
type ReportedEndpoint,
+ routeWithLabel,
} from "./routes.ts";
const RETRY_BASE_DELAY_MS = 1_000;
@@ -265,11 +266,22 @@ export class EnvironmentSupervisor extends Context.Service<
}
>()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") {}
+const entryWithLabel = (entry: ConnectionCatalogEntry, label: string) =>
+ entryWithRoutes(
+ entry,
+ connectionRoutes(entry).map((route) => routeWithLabel(route, label)),
+ );
+
+/** What the registry holds. Sessions only ever see the service. */
+export type ManagedEnvironmentSupervisor = EnvironmentSupervisor["Service"] & {
+ readonly setLabel: (label: string) => Effect.Effect;
+};
+
export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
entry: ConnectionCatalogEntry,
options?: EnvironmentSupervisorOptions,
): Effect.fn.Return<
- EnvironmentSupervisor["Service"],
+ ManagedEnvironmentSupervisor,
never,
| Connectivity.Connectivity
| ConnectionDriver.ConnectionDriver
@@ -355,7 +367,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
Effect.gen(function* () {
const updated = yield* learn({ activeRoute, reported });
if (Option.isSome(updated)) {
- yield* Ref.set(currentEntry, updated.value);
+ // A rename can land after the registry built this entry.
+ yield* Ref.update(currentEntry, (current) =>
+ entryWithLabel(updated.value, current.target.label),
+ );
yield* requestBetterRouteCheck(lease);
}
}),
@@ -1240,13 +1255,19 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
yield* Effect.addFinalizer(() => Queue.shutdown(signals).pipe(Effect.andThen(clearLease)));
- return EnvironmentSupervisor.of({
- target,
- state,
- session,
- prepared,
- connect,
- disconnect,
- retryNow,
- });
+ const setLabel = (label: string) =>
+ Ref.update(currentEntry, (current) => entryWithLabel(current, label));
+
+ return {
+ ...EnvironmentSupervisor.of({
+ target,
+ state,
+ session,
+ prepared,
+ connect,
+ disconnect,
+ retryNow,
+ }),
+ setLabel,
+ };
});
diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts
index 206f75a600416..40123896b62c8 100644
--- a/packages/client-runtime/src/state/connections.ts
+++ b/packages/client-runtime/src/state/connections.ts
@@ -182,6 +182,15 @@ export function createEnvironmentCatalogAtoms(
Effect.flatMap((registry) => registry.reorderRoutes(input.environmentId, input.routeIds)),
),
});
+ const setLabel = createRuntimeCommand(runtime, {
+ label: "environment-catalog:set-label",
+ scheduler: commandScheduler,
+ concurrency: serial,
+ execute: (input: { readonly environmentId: EnvironmentIdType; readonly label: string }) =>
+ EnvironmentRegistry.EnvironmentRegistry.pipe(
+ Effect.flatMap((registry) => registry.setLabel(input.environmentId, input.label)),
+ ),
+ });
const retryNow = createRuntimeCommand(runtime, {
label: "environment-catalog:retry-now",
scheduler: commandScheduler,
@@ -208,5 +217,6 @@ export function createEnvironmentCatalogAtoms(
removeRelayEnvironments,
retryNow,
setEnabled,
+ setLabel,
};
}
diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts
index af2ab6d0575a6..4bc3bbbda986c 100644
--- a/packages/client-runtime/src/state/threads-atoms.test.ts
+++ b/packages/client-runtime/src/state/threads-atoms.test.ts
@@ -133,6 +133,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?
remove: () => Effect.die("Unexpected environment removal"),
removeRoute: () => Effect.die("Unexpected route removal"),
reorderRoutes: () => Effect.die("Unexpected route reorder"),
+ setLabel: () => Effect.die("Unexpected label update"),
removeRelayEnvironments: () => Effect.die("Unexpected environment removal"),
retryNow: () => Effect.void,
setEnabled: () => Effect.die("Unexpected environment toggle"),