1import { SERVER_VERSION } from "../version.ts";
2import {
3 EnvironmentId,
4 ORCHESTRATION_PROTOCOL_VERSION,
5 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
6 type ExecutionEnvironmentDescriptor,
7} from "@t3tools/contracts";
8import * as HostProcess from "@t3tools/shared/HostProcess";
9import * as Context from "effect/Context";
10import * as Crypto from "effect/Crypto";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import * as Path from "effect/Path";
15import * as Schema from "effect/Schema";
16
17import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
18import { resolveServerInstallation } from "../cli/invocation.ts";
19import { readAgentActivityPublishingActive } from "../cloud/config.ts";
20import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts";
21import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts";
22import * as ServerConfig from "../config.ts";
23import * as ProcessRunner from "../processRunner.ts";
24import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
25import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts";
26
27export class ServerEnvironmentIdPersistenceError extends Schema.TaggedError<ServerEnvironmentIdPersistenceError>()(
28 "ServerEnvironmentIdPersistenceError",
29 {
30 operation: Schema.Literals(["check", "read", "write", "initialize"]),
31 environmentIdPath: Schema.String,
32 cause: Schema.optional(Schema.Defect()),
33 },
34) {
35 override get message(): string {
36 if (this.operation === "initialize") {
37 return `Server environment ID file is missing or empty after initialization at '${this.environmentIdPath}'.`;
38 }
39 return `Server environment ID ${this.operation} failed at '${this.environmentIdPath}'.`;
40 }
41}
42
43export class ServerEnvironment extends Context.Service<
44 ServerEnvironment,
45 {
46 readonly getEnvironmentId: Effect.Effect<EnvironmentId>;
47 readonly getDescriptor: Effect.Effect<ExecutionEnvironmentDescriptor>;
48 }
49>()("t3/environment/ServerEnvironment") {}
50
51export class ServerEnvironmentIdentity extends Context.Service<
52 ServerEnvironmentIdentity,
53 {
54 readonly getEnvironmentId: Effect.Effect<EnvironmentId>;
55 }
56>()("t3/environment/ServerEnvironment/ServerEnvironmentIdentity") {}
57
58function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] {
59 switch (platform) {
60 case "darwin":
61 return "darwin";
62 case "linux":
63 return "linux";
64 case "win32":
65 return "windows";
66 default:
67 return "unknown";
68 }
69}
70
71function platformArch(
72 architecture: NodeJS.Architecture,
73): ExecutionEnvironmentDescriptor["platform"]["arch"] {
74 switch (architecture) {
75 case "arm64":
76 return "arm64";
77 case "x64":
78 return "x64";
79 default:
80 return "other";
81 }
82}
83
84const makeIdentity = Effect.gen(function* () {
85 const fileSystem = yield* FileSystem.FileSystem;
86 const serverConfig = yield* ServerConfig.ServerConfig;
87 const crypto = yield* Crypto.Crypto;
88
89 const readPersistedEnvironmentId = Effect.gen(function* () {
90 const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe(
91 Effect.mapError(
92 (cause) =>
93 new ServerEnvironmentIdPersistenceError({
94 operation: "check",
95 environmentIdPath: serverConfig.environmentIdPath,
96 cause,
97 }),
98 ),
99 );
100 if (!exists) {
101 return null;
102 }
103
104 const raw = yield* fileSystem.readFileString(serverConfig.environmentIdPath).pipe(
105 Effect.map((value) => value.trim()),
106 Effect.mapError(
107 (cause) =>
108 new ServerEnvironmentIdPersistenceError({
109 operation: "read",
110 environmentIdPath: serverConfig.environmentIdPath,
111 cause,
112 }),
113 ),
114 );
115
116 return raw.length > 0 ? raw : null;
117 });
118
119 const persistEnvironmentId = Effect.fn("ServerEnvironmentIdentity.persistEnvironmentId")(
120 function* (value: string, mode: "create" | "recover") {
121 const destinationPath =
122 mode === "recover"
123 ? `${serverConfig.environmentIdPath}.recovery`
124 : serverConfig.environmentIdPath;
125 const tempPath = yield* fileSystem.makeTempFileScoped({
126 directory: serverConfig.stateDir,
127 prefix: ".environment-id-",
128 });
129 yield* fileSystem.writeFileString(tempPath, `${value}\n`);
130 // Publish the completed file without replacing an ID created by another process.
131 yield* fileSystem.link(tempPath, destinationPath).pipe(
132 Effect.catchIf(
133 (cause) => cause.reason._tag === "AlreadyExists",
134 () => Effect.void,
135 ),
136 );
137 if (mode === "recover") {
138 // Keep the recovery ID so delayed initializers also publish the same winner.
139 yield* fileSystem.remove(tempPath);
140 yield* fileSystem.copyFile(destinationPath, tempPath);
141 yield* fileSystem.rename(tempPath, serverConfig.environmentIdPath);
142 }
143 },
144 Effect.scoped,
145 Effect.mapError(
146 (cause) =>
147 new ServerEnvironmentIdPersistenceError({
148 operation: "write",
149 environmentIdPath: serverConfig.environmentIdPath,
150 cause,
151 }),
152 ),
153 );
154
155 const environmentIdRaw = yield* Effect.gen(function* () {
156 const persisted = yield* readPersistedEnvironmentId;
157 if (persisted) {
158 return persisted;
159 }
160
161 const generated = yield* crypto.randomUUIDv4();
162 yield* persistEnvironmentId(generated, "create");
163 let winner = yield* readPersistedEnvironmentId;
164 if (winner === null) {
165 yield* persistEnvironmentId(generated, "recover");
166 winner = yield* readPersistedEnvironmentId;
167 }
168 if (winner === null) {
169 return yield* new ServerEnvironmentIdPersistenceError({
170 operation: "initialize",
171 environmentIdPath: serverConfig.environmentIdPath,
172 });
173 }
174 return winner;
175 });
176
177 const environmentId = EnvironmentId.make(environmentIdRaw);
178 return ServerEnvironmentIdentity.of({
179 getEnvironmentId: Effect.succeed(environmentId),
180 });
181});
182
183/** @public Service construction is part of the canonical Effect module API. */
184export const make = Effect.gen(function* () {
185 const path = yield* Path.Path;
186 const serverConfig = yield* ServerConfig.ServerConfig;
187 const secrets = yield* ServerSecretStore.ServerSecretStore;
188 const identity = yield* ServerEnvironmentIdentity;
189 const hostPlatform = yield* HostProcess.Platform;
190 const hostArchitecture = yield* HostProcess.Architecture;
191 const environmentId = yield* identity.getEnvironmentId;
192 const cwdBaseName = path.basename(serverConfig.cwd).trim();
193 const label = yield* resolveServerEnvironmentLabel({ cwdBaseName });
194 const machine = yield* detectServerEnvironmentMachineKind();
195 const launcher = yield* resolveServiceLauncherMode();
196 const serverSelfUpdate = resolveServerSelfUpdateCapability({
197 desktopManaged: serverConfig.mode === "desktop",
198 launcherManaged: launcher.managed,
199 });
200 const serverInstallation = serverSelfUpdate === null ? yield* resolveServerInstallation : null;
201 // Static is correct: the control fd is known at bootstrap, and the desktop
202 // app and its bundled server ship in one artifact, so a present fd means
203 // the app speaks the requestDesktopUpdate protocol. WSL backends never get
204 // the fd and correctly do not advertise.
205 const desktopAppUpdate =
206 serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined;
207
208 const descriptor: ExecutionEnvironmentDescriptor = {
209 environmentId,
210 label,
211 platform: {
212 os: platformOs(hostPlatform),
213 arch: platformArch(hostArchitecture),
214 ...(machine === null ? {} : { machine }),
215 },
216 serverVersion: SERVER_VERSION,
217 orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION,
218 capabilities: {
219 repositoryIdentity: true,
220 connectionProbe: true,
221 attachmentUploads: true,
222 questionAttachments: true,
223 fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
224 pullRequests: true,
225 pullRequestChecks: true,
226 inlineMessageContext: true,
227 requiredWorktreeBootstrap: true,
228 threadSettlement: true,
229 uiSettingsSync: true,
230 threadAutoSettlement: true,
231 storageCleanup: true,
232 storageCleanupRun: true,
233 otlpEndpointCheck: true,
234 projectWorktreeCleanup: true,
235 settledWorktreeCleanup: true,
236 worktreesDirectory: true,
237 threadAutoSettlementScope: true,
238 threadRestartContinuation: true,
239 projectSettingsOverrides: true,
240 projectProviderInstanceOverrides: true,
241 threadSnooze: true,
242 environmentThemes: true,
243 usageLimitSources: true,
244 usagePriceOverrides: true,
245 usageModelAliases: true,
246 threadPinning: true,
247 threadPinReorder: true,
248 threadActiveReorder: true,
249 threadAutoSettleOptOut: true,
250 threadTitleRegeneration: true,
251 threadVisitedTracking: true,
252 threadPullRequests: true,
253 threadPullRequestWatch: true,
254 pullRequestStackActions: true,
255 threadPullRequestLinking: true,
256 serverResolvedCommandContext: true,
257 environmentIcon: true,
258 projectCloneTracking: true,
259 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
260 ...(serverInstallation === null ? {} : { serverInstallation }),
261 // V2 restart recovery uses the environment-owned opt-in. The old
262 // per-update request flag is not wired into the V2 update RPC path.
263 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate
264 ? { serverSelfUpdateProgress: true }
265 : {}),
266 ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}),
267 serverBrowser: true,
268 },
269 };
270
271 return ServerEnvironment.of({
272 getEnvironmentId: Effect.succeed(environmentId),
273 // The publish opt-in and relay link change at runtime (`t3 connect
274 // publish`, the client settings toggle), so the capability is read per
275 // descriptor request rather than baked in at startup.
276 getDescriptor: readAgentActivityPublishingActive(secrets).pipe(
277 Effect.map((agentActivityPublishing) => ({
278 ...descriptor,
279 capabilities: { ...descriptor.capabilities, agentActivityPublishing },
280 })),
281 ),
282 });
283});
284
285export const layerIdentity = Layer.effect(ServerEnvironmentIdentity, makeIdentity);
286
287/**
288 * ServerEnvironment is acquired from persisted filesystem and host-process
289 * state. It intentionally has no fallback Layer.succeed value: callers must
290 * provide the external platform services, a ServerConfig, and the
291 * ServerSecretStore backing the descriptor's publishing capability.
292 */
293export const layer = Layer.effect(ServerEnvironment, make).pipe(
294 Layer.provideMerge(layerIdentity),
295 Layer.provide(ProcessRunner.layer),
296);