Skip to content
Source
Sign inSign up

ServerEnvironment.test.ts

338 lines14.4 KB

1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
4import { expect, it } from "@effect/vitest";
5import * as SecureRandom from "effect/crypto/SecureRandom";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10​
11import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
12import * as HostProcess from "@t3tools/shared/HostProcess";
13import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
14​
15import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
16import {
17 PUBLISH_AGENT_ACTIVITY_SECRET,
18 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
19 RELAY_URL_SECRET,
20} from "../cloud/config.ts";
21import * as ServerConfig from "../config.ts";
22import * as ServerEnvironment from "./ServerEnvironment.ts";
23​
24const layerServerEnvironment = (baseDir: string) =>
25 ServerEnvironment.layer.pipe(
26 Layer.provide(ServerSecretStore.layer),
27 Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
28 );
29​
30const layerEmptySecretStore = Layer.succeed(
31 ServerSecretStore.ServerSecretStore,
32 ServerSecretStore.ServerSecretStore.of({
33 get: () => Effect.succeedNone,
34 set: () => Effect.void,
35 create: () => Effect.void,
36 getOrCreateRandom: () => Effect.succeed(new Uint8Array()),
37 remove: () => Effect.void,
38 }),
39);
40​
41const makeServerConfig = Effect.fn(function* (baseDir: string) {
42 const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined);
43​
44 return {
45 ...derivedPaths,
46 logLevel: "Error",
47 traceMinLevel: "Info",
48 traceTimingEnabled: true,
49 traceBatchWindowMs: 200,
50 traceMaxBytes: 10 * 1024 * 1024,
51 traceMaxFiles: 10,
52 otlpTracesUrl: undefined,
53 otlpMetricsUrl: undefined,
54 otlpLogsUrl: undefined,
55 otlpTracesExport: DEFAULT_SIGNAL_EXPORT,
56 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
57 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
58 otelEnvironment: OtelEnvironment.none,
59 cwd: process.cwd(),
60 baseDir,
61 mode: "web",
62 autoBootstrapProjectFromCwd: false,
63 logWebSocketEvents: false,
64 tailscaleServeEnabled: false,
65 tailscaleServePort: 443,
66 port: 0,
67 host: undefined,
68 desktopBootstrapToken: undefined,
69 staticDir: undefined,
70 devUrl: undefined,
71 devAllowedOrigins: [],
72 noBrowser: false,
73 startupPresentation: "browser",
74 } satisfies ServerConfig.ServerConfig["Service"];
75});
76​
77it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ServerEnvironmentLive", (it) => {
78 it.effect("publishes proven install ownership only for manually updated servers", () =>
79 Effect.gen(function* () {
80 const fs = yield* FileSystem.FileSystem;
81 const baseDir = yield* fs.makeTempDirectoryScoped();
82 const prefix = `${baseDir}/node`;
83 const entry = `${prefix}/lib/node_modules/t3/dist/bin.mjs`;
84 yield* fs.makeDirectory(`${prefix}/lib/node_modules/t3/dist`, { recursive: true });
85 yield* fs.makeDirectory(`${prefix}/bin`, { recursive: true });
86 yield* fs.writeFileString(entry, "");
87 yield* fs.writeFileString(
88 `${prefix}/lib/node_modules/t3/package.json`,
89 '{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}',
90 );
91 yield* fs.symlink(entry, `${prefix}/bin/t3`);
92 const config = yield* makeServerConfig(baseDir);
93 yield* fs.makeDirectory(config.stateDir, { recursive: true });
94 for (const mode of ["web", "desktop"] as const) {
95 const descriptor = yield* Effect.gen(function* () {
96 const environment = yield* ServerEnvironment.ServerEnvironment;
97 return yield* environment.getDescriptor;
98 }).pipe(
99 Effect.provide(
100 ServerEnvironment.layer.pipe(
101 Layer.provide(layerEmptySecretStore),
102 Layer.provide(ServerConfig.layer({ ...config, mode })),
103 ),
104 ),
105 Effect.provideService(HostProcess.Arguments, ["node", entry]),
106 Effect.provideService(HostProcess.IsExecutable, false),
107 Effect.provideService(HostProcess.Platform, "linux"),
108 Effect.provideService(HostProcess.Environment, {}),
109 );
110 expect(descriptor.capabilities.serverInstallation).toEqual(
111 mode === "web" ? { kind: "npm-global", prefix } : undefined,
112 );
113 expect(descriptor.capabilities.serverSelfUpdate).toBe(
114 mode === "web" ? undefined : "desktop-managed",
115 );
116 expect(descriptor.capabilities).toMatchObject({
117 settledWorktreeCleanup: true,
118 storageCleanupRun: true,
119 projectWorktreeCleanup: true,
120 worktreesDirectory: true,
121 threadAutoSettlementScope: true,
122 });
123 }
124 }),
125 );
126 it.effect.each([
127 { desktopAppUpdate: false, advertised: undefined },
128 { desktopAppUpdate: true, advertised: true },
129 ])(
130 "advertises desktop app updates only when the desktop can install them ($desktopAppUpdate)",
131 ({ desktopAppUpdate, advertised }) =>
132 Effect.gen(function* () {
133 const fs = yield* FileSystem.FileSystem;
134 const baseDir = yield* fs.makeTempDirectoryScoped();
135 const config = yield* makeServerConfig(baseDir);
136 yield* fs.makeDirectory(config.stateDir, { recursive: true });
137 const descriptor = yield* Effect.gen(function* () {
138 const environment = yield* ServerEnvironment.ServerEnvironment;
139 return yield* environment.getDescriptor;
140 }).pipe(
141 Effect.provide(
142 ServerEnvironment.layer.pipe(
143 Layer.provide(layerEmptySecretStore),
144 Layer.provide(
145 ServerConfig.layer({
146 ...config,
147 mode: "desktop",
148 desktopTelemetryControlFd: 5,
149 desktopAppUpdate,
150 }),
151 ),
152 ),
153 ),
154 Effect.provideService(HostProcess.Environment, {}),
155 );
156 const capabilities = descriptor.capabilities;
157 expect({
158 serverSelfUpdate: capabilities.serverSelfUpdate,
159 desktopAppUpdate: capabilities.desktopAppUpdate,
160 desktopAppChannelSwitch: capabilities.desktopAppChannelSwitch,
161 serverSelfUpdateProgress: capabilities.serverSelfUpdateProgress,
162 }).toEqual({
163 serverSelfUpdate: "desktop-managed",
164 desktopAppUpdate: advertised,
165 desktopAppChannelSwitch: advertised,
166 serverSelfUpdateProgress: advertised,
167 });
168 }),
169 );
170 it.effect.each([
171 { platform: "darwin", architecture: "arm64", supported: true },
172 { platform: "linux", architecture: "x64", supported: true },
173 { platform: "linux", architecture: "x64", libc: "musl", supported: false },
174 { platform: "win32", architecture: "arm64", supported: false },
175 { platform: "linux", architecture: "arm", supported: false },
176 ] as const)("advertises voice transcription on $platform-$architecture: $supported", (host) =>
177 Effect.gen(function* () {
178 const fileSystem = yield* FileSystem.FileSystem;
179 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
180 prefix: "t3-server-environment-speech-test-",
181 });
182 const descriptor = yield* Effect.gen(function* () {
183 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
184 return yield* serverEnvironment.getDescriptor;
185 }).pipe(
186 Effect.provide(layerServerEnvironment(baseDir)),
187 Effect.provideService(HostProcess.Platform, host.platform),
188 Effect.provideService(HostProcess.Architecture, host.architecture),
189 Effect.provideService(HostProcess.LinuxLibc, "libc" in host ? host.libc : "gnu"),
190 );
191 expect(descriptor.capabilities.voiceTranscription).toBe(host.supported);
192 }),
193 );
194 it.effect.each([
195 { name: "missing", content: undefined },
196 { name: "empty", content: "" },
197 { name: "whitespace-only", content: " \t\n" },
198 ])("concurrent initializers recover a $name environment id file", ({ content }) =>
199 Effect.gen(function* () {
200 const fileSystem = yield* FileSystem.FileSystem;
201 const random = yield* SecureRandom.SecureRandom;
202 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
203 prefix: "t3-server-environment-concurrent-test-",
204 });
205 const serverConfig = yield* makeServerConfig(baseDir);
206 yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true });
207 if (content !== undefined) {
208 yield* fileSystem.writeFileString(serverConfig.environmentIdPath, content);
209 }
210 const bothGenerated = yield* Deferred.make<void>();
211 const bothReadEmpty = yield* Deferred.make<void>();
212 const firstInitialized = yield* Deferred.make<void>();
213 let remaining = 2;
214 let emptyReads = 0;
215 const readIdentity = Effect.gen(function* () {
216 const identity = yield* ServerEnvironment.ServerEnvironmentIdentity;
217 return yield* identity.getEnvironmentId;
218 }).pipe(
219 Effect.tap(() => Deferred.succeed(firstInitialized, undefined)),
220 Effect.provide(Layer.fresh(ServerEnvironment.layerIdentity)),
221 Effect.provideService(ServerConfig.ServerConfig, serverConfig),
222 Effect.provideService(FileSystem.FileSystem, {
223 ...fileSystem,
224 readFileString: (path) =>
225 fileSystem.readFileString(path).pipe(
226 Effect.tap(
227 Effect.fn(function* (value) {
228 if (path !== serverConfig.environmentIdPath || remaining > 0 || value.trim()) {
229 return;
230 }
231 if (++emptyReads === 2) {
232 yield* Deferred.succeed(bothReadEmpty, undefined);
233 yield* Deferred.await(firstInitialized);
234 } else {
235 yield* Deferred.await(bothReadEmpty);
236 }
237 }),
238 ),
239 ),
240 }),
241 Effect.provideService(SecureRandom.SecureRandom, {
242 ...random,
243 uuidV4: (() =>
244 Effect.gen(function* () {
245 const id = yield* random.uuidV4();
246 if (--remaining === 0) {
247 yield* Deferred.succeed(bothGenerated, undefined);
248 }
249 yield* Deferred.await(bothGenerated);
250 return id;
251 })) as SecureRandom.SecureRandom["Service"]["uuidV4"],
252 }),
253 );
254​
255 const [first, second] = yield* Effect.all([readIdentity, readIdentity], {
256 concurrency: "unbounded",
257 });
258 const persisted = yield* fileSystem.readFileString(serverConfig.environmentIdPath);
259​
260 expect(first).toBe(second);
261 expect(persisted.trim()).toBe(first);
262 }),
263 );
264​
265 it.effect("persists the environment id across service restarts", () =>
266 Effect.gen(function* () {
267 const fileSystem = yield* FileSystem.FileSystem;
268 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
269 prefix: "t3-server-environment-test-",
270 });
271​
272 const first = yield* Effect.gen(function* () {
273 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
274 return yield* serverEnvironment.getDescriptor;
275 }).pipe(Effect.provide(layerServerEnvironment(baseDir)));
276 const second = yield* Effect.gen(function* () {
277 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
278 return yield* serverEnvironment.getDescriptor;
279 }).pipe(Effect.provide(layerServerEnvironment(baseDir)));
280​
281 expect(first.environmentId).toBe(second.environmentId);
282 expect(first.orchestrationProtocolVersion).toBe(ORCHESTRATION_PROTOCOL_VERSION);
283 expect(second.capabilities.repositoryIdentity).toBe(true);
284 expect(second.capabilities.connectionProbe).toBe(true);
285 expect(second.capabilities.attachmentUploads).toBe(true);
286 expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
287 expect(second.capabilities.pullRequests).toBe(true);
288 expect(second.capabilities.requiredWorktreeBootstrap).toBe(true);
289 expect(second.capabilities.usagePriceOverrides).toBe(true);
290 expect(second.capabilities.threadActiveReorder).toBe(true);
291 expect(second.capabilities.threadTitleRegeneration).toBe(true);
292 expect(second.capabilities.threadPullRequests).toBe(true);
293 expect(second.capabilities.threadPullRequestLinking).toBe(true);
294 expect(second.capabilities.serverResolvedCommandContext).toBe(true);
295 expect(second.capabilities.projectProviderInstanceOverrides).toBe(true);
296 expect(second.capabilities.agentActivityPublishing).toBe(false);
297 }),
298 );
299​
300 it.effect("reports agent activity publishing from the current secret state", () =>
301 Effect.gen(function* () {
302 const fileSystem = yield* FileSystem.FileSystem;
303 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
304 prefix: "t3-server-environment-publish-test-",
305 });
306 const layerTest = Layer.mergeAll(
307 ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)),
308 ServerSecretStore.layer,
309 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
310​
311 yield* Effect.gen(function* () {
312 const secrets = yield* ServerSecretStore.ServerSecretStore;
313 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
314 const encode = (value: string) => new TextEncoder().encode(value);
315​
316 const unlinked = yield* serverEnvironment.getDescriptor;
317 expect(unlinked.capabilities.agentActivityPublishing).toBe(false);
318​
319 yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("true"));
320 const withoutLink = yield* serverEnvironment.getDescriptor;
321 expect(withoutLink.capabilities.agentActivityPublishing).toBe(false);
322​
323 yield* secrets.set(RELAY_URL_SECRET, encode(""));
324 yield* secrets.set(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, encode("credential"));
325 const emptyUrl = yield* serverEnvironment.getDescriptor;
326 expect(emptyUrl.capabilities.agentActivityPublishing).toBe(false);
327​
328 yield* secrets.set(RELAY_URL_SECRET, encode("https://relay.example"));
329 const linked = yield* serverEnvironment.getDescriptor;
330 expect(linked.capabilities.agentActivityPublishing).toBe(true);
331​
332 yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("false"));
333 const disabled = yield* serverEnvironment.getDescriptor;
334 expect(disabled.capabilities.agentActivityPublishing).toBe(false);
335 }).pipe(Effect.provide(layerTest));
336 }),
337 );
338});