| 1 | import { SERVER_VERSION } from "../version.ts"; |
| 2 | import { |
| 3 | EnvironmentId, |
| 4 | ORCHESTRATION_PROTOCOL_VERSION, |
| 5 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, |
| 6 | type ExecutionEnvironmentDescriptor, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 9 | import * as Context from "effect/Context"; |
| 10 | import * as Crypto from "effect/Crypto"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as FileSystem from "effect/FileSystem"; |
| 13 | import * as Layer from "effect/Layer"; |
| 14 | import * as Path from "effect/Path"; |
| 15 | import * as Schema from "effect/Schema"; |
| 16 | |
| 17 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 18 | import { resolveServerInstallation } from "../cli/invocation.ts"; |
| 19 | import { readAgentActivityPublishingActive } from "../cloud/config.ts"; |
| 20 | import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; |
| 21 | import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; |
| 22 | import * as ServerConfig from "../config.ts"; |
| 23 | import * as ProcessRunner from "../processRunner.ts"; |
| 24 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; |
| 25 | import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts"; |
| 26 | |
| 27 | export 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 | |
| 43 | export 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 | |
| 51 | export class ServerEnvironmentIdentity extends Context.Service< |
| 52 | ServerEnvironmentIdentity, |
| 53 | { |
| 54 | readonly getEnvironmentId: Effect.Effect<EnvironmentId>; |
| 55 | } |
| 56 | >()("t3/environment/ServerEnvironment/ServerEnvironmentIdentity") {} |
| 57 | |
| 58 | function 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 | |
| 71 | function 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 | |
| 84 | const 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. */ |
| 184 | export 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 | |
| 285 | export 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 | */ |
| 293 | export const layer = Layer.effect(ServerEnvironment, make).pipe( |
| 294 | Layer.provideMerge(layerIdentity), |
| 295 | Layer.provide(ProcessRunner.layer), |
| 296 | ); |