1import type { ServerConfig } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3
4import { environmentEndpointUrl } from "../environment/endpoint.ts";
5import type { ConnectionCatalogEntry } from "./catalog.ts";
6import type { ConnectionTarget, SupervisorConnectionState } from "./model.ts";
7import { connectionRouteId, connectionRoutes, routeHttpBaseUrl } from "./routes.ts";
8
9export type EnvironmentConnectionPhase =
10 | "available"
11 | "offline"
12 | "connecting"
13 | "reconnecting"
14 | "connected"
15 | "error"
16 | "unsupported";
17
18export interface EnvironmentConnectionPresentation {
19 readonly phase: EnvironmentConnectionPhase;
20 readonly error: string | null;
21 readonly traceId: string | null;
22}
23
24export interface EnvironmentPresentation {
25 readonly entry: ConnectionCatalogEntry;
26 readonly connection: EnvironmentConnectionPresentation;
27 readonly serverConfig: ServerConfig | null;
28}
29
30export function presentConnectionState(
31 state: SupervisorConnectionState,
32): EnvironmentConnectionPresentation {
33 switch (state.phase) {
34 case "available":
35 return { phase: "available", error: null, traceId: null };
36 case "offline":
37 return { phase: "offline", error: null, traceId: null };
38 case "connecting":
39 return {
40 phase: state.attempt <= 1 && state.lastFailure === null ? "connecting" : "reconnecting",
41 error: state.lastFailure?.message ?? null,
42 traceId: state.lastFailure?.traceId ?? null,
43 };
44 case "connected":
45 return { phase: "connected", error: null, traceId: null };
46 case "backoff":
47 return {
48 phase: "reconnecting",
49 error: state.lastFailure?.message ?? null,
50 traceId: state.lastFailure?.traceId ?? null,
51 };
52 case "blocked":
53 return {
54 phase: state.lastFailure?.reason === "unsupported" ? "unsupported" : "error",
55 error: state.lastFailure?.message ?? null,
56 traceId: state.lastFailure?.traceId ?? null,
57 };
58 }
59}
60
61export function connectionStatusText(connection: EnvironmentConnectionPresentation): string {
62 switch (connection.phase) {
63 case "available":
64 return "Available";
65 case "offline":
66 return "Offline";
67 case "connecting":
68 return "Connecting...";
69 case "reconnecting":
70 return connection.error
71 ? `Failed to connect. Reconnecting... Reason: ${connection.error}`
72 : "Reconnecting...";
73 case "connected":
74 return "Connected";
75 case "unsupported":
76 return "Client not supported";
77 case "error":
78 return connection.error
79 ? `Connection failed. Reason: ${connection.error}`
80 : "Connection failed";
81 }
82}
83
84export function connectionStatusTitle(connection: EnvironmentConnectionPresentation): string {
85 if (connection.phase === "reconnecting" && connection.error) {
86 return "Failed to connect. Reconnecting...";
87 }
88 return connectionStatusText({ ...connection, error: null });
89}
90
91export function presentEnvironmentConnection(
92 state: SupervisorConnectionState,
93): EnvironmentConnectionPresentation {
94 return presentConnectionState(state);
95}
96
97/**
98 * The address an agent outside T3 (Claude Code, Codex) uses to reach this
99 * environment's MCP server: the route this device is connected over, since an
100 * agent beside this client can reach it too, else the first route in
101 * preference order that has an address. SSH connections ride a local forward
102 * that disappears with the client, so they have no stable address.
103 */
104export function environmentMcpUrl(input: {
105 readonly entry: ConnectionCatalogEntry;
106 readonly relayHttpBaseUrl?: string | undefined;
107 readonly connectedTarget?: ConnectionTarget | null | undefined;
108}): string | null {
109 const connectedRouteId = input.connectedTarget ? connectionRouteId(input.connectedTarget) : null;
110 const routes = connectionRoutes(input.entry);
111 const connectedRoute = routes.find(
112 (route) => connectionRouteId(route.target) === connectedRouteId,
113 );
114 for (const route of connectedRoute ? [connectedRoute, ...routes] : routes) {
115 const httpBaseUrl =
116 route.target._tag === "RelayConnectionTarget"
117 ? (input.relayHttpBaseUrl ?? null)
118 : routeHttpBaseUrl(route);
119 const mcpUrl = httpBaseUrl === null ? null : mcpUrlFromBase(httpBaseUrl);
120 if (mcpUrl !== null) return mcpUrl;
121 }
122 return null;
123}
124
125function mcpUrlFromBase(httpBaseUrl: string): string | null {
126 try {
127 return environmentEndpointUrl(httpBaseUrl, "/mcp");
128 } catch {
129 return null;
130 }
131}
132
133export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null {
134 switch (entry.target._tag) {
135 case "PrimaryConnectionTarget":
136 return entry.target.httpBaseUrl;
137 case "RelayConnectionTarget":
138 return null;
139 case "BearerConnectionTarget":
140 return Option.isSome(entry.profile) && entry.profile.value._tag === "BearerConnectionProfile"
141 ? entry.profile.value.httpBaseUrl
142 : null;
143 case "SshConnectionTarget":
144 return Option.isSome(entry.profile) && entry.profile.value._tag === "SshConnectionProfile"
145 ? `${entry.profile.value.target.username}@${entry.profile.value.target.hostname}`
146 : null;
147 }
148}