History

Use Option and Duration in Effect server paths

Cursor Agent committed 4 months ago

Commit 66d9fcf1 parent 82a9bcc5 files · +337 −280Browse files

Unified · Split

5 files
  1. apps/server/integration/OrchestrationEngineHarness.integration.ts +52 −64
  2. apps/server/src/cloud/ManagedEndpointRuntime.test.ts +146 −93
  3. apps/server/src/cloud/ManagedEndpointRuntime.ts +132 −117
  4. apps/server/src/cloud/http.ts +2 −2
  5. apps/server/src/server.test.ts +5 −4
apps/server/integration/OrchestrationEngineHarness.integration.ts+52−64
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 type OrchestrationEvent,
1010 type OrchestrationThread,
1111 } from "@t3tools/contracts";
12+import * as Duration from "effect/Duration";
1213 import * as Effect from "effect/Effect";
1314 import * as Exit from "effect/Exit";
1415 import * as FileSystem from "effect/FileSystem";
@@ -120,39 +121,45 @@ class WaitForTimeoutError extends Schema.TaggedErrorClass<WaitForTimeoutError>() · 105 unmodified lines
120121 },
121122 ) {}
122123
124+class WaitForRetrySignal extends Schema.TaggedErrorClass<WaitForRetrySignal>()(
125+ "WaitForRetrySignal",
126+ {
127+ description: Schema.String,
128+ },
129+) {}
130+
131+const isWaitForRetrySignal = Schema.is(WaitForRetrySignal);
132+const WAIT_FOR_RETRY_INTERVAL = Duration.millis(10);
133+const WAIT_FOR_DEFAULT_TIMEOUT = Duration.seconds(40);
134+
123135 function waitFor<A, E>(
124136 read: Effect.Effect<A, E>,
125137 predicate: (value: A) => boolean,
126138 description: string,
127− timeoutMs?: number,
139+ timeout?: Duration.Input,
128140 ): Effect.Effect<A, never>;
129141 function waitFor<A, B extends A, E>(
130142 read: Effect.Effect<A, E>,
131143 predicate: (value: A) => value is B,
132144 description: string,
133− timeoutMs?: number,
145+ timeout?: Duration.Input,
134146 ): Effect.Effect<B, never>;
135147 function waitFor<A, E>(
136148 read: Effect.Effect<A, E>,
137149 predicate: (value: A) => boolean,
138150 description: string,
139− timeoutMs = 40_000,
151+ timeout: Duration.Input = WAIT_FOR_DEFAULT_TIMEOUT,
140152 ): Effect.Effect<A, never> {
141− const RETRY_SIGNAL = "wait_for_retry";
142− const retryIntervalMs = 10;
143− const maxRetries = Math.max(0, Math.floor(timeoutMs / retryIntervalMs));
144− const retrySchedule = Schedule.spaced(`${retryIntervalMs} millis`);
145−
146153 return read.pipe(
147− Effect.filterOrFail(predicate, () => RETRY_SIGNAL),
154+ Effect.filterOrFail(predicate, () => new WaitForRetrySignal({ description })),
148155 Effect.retry({
149− schedule: retrySchedule,
150− times: maxRetries,
151− while: (error) => error === RETRY_SIGNAL,
156+ schedule: Schedule.spaced(WAIT_FOR_RETRY_INTERVAL),
157+ while: isWaitForRetrySignal,
158+ }),
159+ Effect.timeoutOrElse({
160+ duration: timeout,
161+ orElse: () => Effect.fail(new WaitForTimeoutError({ description })),
152162 }),
153− Effect.mapError((error) =>
154− error === RETRY_SIGNAL ? new WaitForTimeoutError({ description }) : error,
155− ),
156163 Effect.orDie,
157164 );
158165 }
@@ -185,11 +192,11 @@ export interface OrchestrationIntegrationHarness { · 26 unmodified lines
185192 readonly waitForThread: (
186193 threadId: string,
187194 predicate: (thread: OrchestrationThread) => boolean,
188− timeoutMs?: number,
195+ timeout?: Duration.Input,
189196 ) => Effect.Effect<OrchestrationThread, never>;
190197 readonly waitForDomainEvent: (
191198 predicate: (event: OrchestrationEvent) => boolean,
192− timeoutMs?: number,
199+ timeout?: Duration.Input,
193200 ) => Effect.Effect<ReadonlyArray<OrchestrationEvent>, never>;
194201 readonly waitForPendingApproval: (
195202 requestId: string,
@@ -198,7 +205,7 @@ export interface OrchestrationIntegrationHarness { · 2 unmodified lines
198205 readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;
199206 readonly resolvedAt: string | null;
200207 }) => boolean,
201− timeoutMs?: number,
208+ timeout?: Duration.Input,
202209 ) => Effect.Effect<
203210 {
204211 readonly status: "pending" | "resolved";
@@ -210,11 +217,11 @@ export interface OrchestrationIntegrationHarness { · 5 unmodified lines
210217 readonly waitForReceipt: {
211218 (
212219 predicate: (receipt: OrchestrationRuntimeReceipt) => boolean,
213− timeoutMs?: number,
220+ timeout?: Duration.Input,
214221 ): Effect.Effect<OrchestrationRuntimeReceipt, never>;
215222 <Receipt extends OrchestrationRuntimeReceipt>(
216223 predicate: (receipt: OrchestrationRuntimeReceipt) => receipt is Receipt,
217− timeoutMs?: number,
224+ timeout?: Duration.Input,
218225 ): Effect.Effect<Receipt, never>;
219226 };
220227 readonly dispose: Effect.Effect<void, never>;
@@ -420,29 +427,29 @@ export const makeOrchestrationIntegrationHarness = ( · 199 unmodified lines
420427 yield* Stream.runForEach(runtimeReceiptBus.streamEventsForTest, (receipt) =>
421428 Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid),
422429 ).pipe(Effect.forkIn(scope));
423− yield* Effect.sleep(10);
430+ yield* Effect.sleep(Duration.millis(10));
424431
425432 const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = (
426433 threadId,
427434 predicate,
428− timeoutMs,
435+ timeout,
429436 ) =>
430437 waitFor(
431438 snapshotQuery
432439 .getSnapshot()
433440 .pipe(
434− Effect.map(
435− (snapshot) => snapshot.threads.find((thread) => thread.id === threadId) ?? null,
441+ Effect.map((snapshot) =>
442+ Option.fromUndefinedOr(snapshot.threads.find((thread) => thread.id === threadId)),
436443 ),
437444 ),
438− (thread): thread is OrchestrationThread => thread !== null && predicate(thread),
445+ (thread) => Option.isSome(thread) && predicate(thread.value),
439446 `projected thread '${threadId}'`,
440− timeoutMs,
441− ) as Effect.Effect<OrchestrationThread, never>;
447+ timeout,
448+ ).pipe(Effect.map(Option.getOrThrow));
442449
443450 const waitForDomainEvent: OrchestrationIntegrationHarness["waitForDomainEvent"] = (
444451 predicate,
445− timeoutMs,
452+ timeout,
446453 ) =>
447454 waitFor(
448455 Stream.runCollect(engine.readEvents(0)).pipe(
@@ -450,68 +457,49 @@ export const makeOrchestrationIntegrationHarness = ( · 1 unmodified line
450457 ),
451458 (events) => events.some(predicate),
452459 "domain event",
453− timeoutMs,
460+ timeout,
454461 );
455462
456463 const waitForPendingApproval: OrchestrationIntegrationHarness["waitForPendingApproval"] = (
457464 requestId,
458465 predicate,
459− timeoutMs,
466+ timeout,
460467 ) =>
461468 waitFor(
462469 pendingApprovalRepository
463470 .getByRequestId({ requestId: ApprovalRequestId.make(requestId) })
464471 .pipe(
465− Effect.map((row) =>
466− Option.match(row, {
467− onNone: () => null,
468− onSome: (value) => ({
469− status: value.status,
470− decision: value.decision,
471− resolvedAt: value.resolvedAt,
472− }),
473− }),
472+ Effect.map(
473+ Option.map((value) => ({
474+ status: value.status,
475+ decision: value.decision,
476+ resolvedAt: value.resolvedAt,
477+ })),
474478 ),
475479 ),
476− (
477− row,
478− ): row is {
479− readonly status: "pending" | "resolved";
480− readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;
481− readonly resolvedAt: string | null;
482− } => row !== null && predicate(row),
480+ (row) => Option.isSome(row) && predicate(row.value),
483481 `pending approval '${requestId}'`,
484− timeoutMs,
485− ) as Effect.Effect<
486− {
487− readonly status: "pending" | "resolved";
488− readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null;
489− readonly resolvedAt: string | null;
490− },
491− never
492− >;
482+ timeout,
483+ ).pipe(Effect.map(Option.getOrThrow));
493484
494485 function waitForReceipt(
495486 predicate: (receipt: OrchestrationRuntimeReceipt) => boolean,
496− timeoutMs?: number,
487+ timeout?: Duration.Input,
497488 ): Effect.Effect<OrchestrationRuntimeReceipt, never>;
498489 function waitForReceipt<Receipt extends OrchestrationRuntimeReceipt>(
499490 predicate: (receipt: OrchestrationRuntimeReceipt) => receipt is Receipt,
500− timeoutMs?: number,
491+ timeout?: Duration.Input,
501492 ): Effect.Effect<Receipt, never>;
502493 function waitForReceipt(
503494 predicate: (receipt: OrchestrationRuntimeReceipt) => boolean,
504− timeoutMs?: number,
495+ timeout?: Duration.Input,
505496 ) {
506497 const readMatchingReceipt = Ref.get(receiptHistory).pipe(
507− Effect.map((history) => history.find(predicate)),
498+ Effect.map((history) => Option.fromUndefinedOr(history.find(predicate))),
508499 );
509500
510− return waitFor(
511− readMatchingReceipt,
512− (receipt): receipt is OrchestrationRuntimeReceipt => receipt !== undefined,
513− "runtime receipt",
514− timeoutMs,
501+ return waitFor(readMatchingReceipt, Option.isSome, "runtime receipt", timeout).pipe(
502+ Effect.map(Option.getOrThrow),
515503 );
516504 }
517505
apps/server/src/cloud/ManagedEndpointRuntime.test.ts+146−93
@@ -1,4 +1,4 @@
1−import { describe, expect, it } from "@effect/vitest";
1+import { assert, describe, it } from "@effect/vitest";
22 import { vi } from "vite-plus/test";
33 import * as Deferred from "effect/Deferred";
44 import * as Effect from "effect/Effect";
@@ -81,21 +81,24 @@ function makeHandle(input: { · 76 unmodified lines
8181
8282 describe("CloudManagedEndpointRuntime", () => {
8383 it("classifies Cloudflare connection and warning output", () => {
84− expect(
84+ assert.equal(
8585 ManagedEndpointRuntime.classifyRelayClientOutput(
8686 "2026-06-17T02:00:00Z INF Registered tunnel connection connIndex=0",
8787 ),
88− ).toBe("connected");
89− expect(
88+ "connected",
89+ );
90+ assert.equal(
9091 ManagedEndpointRuntime.classifyRelayClientOutput(
9192 "2026-06-17T02:00:00Z ERR Failed to serve tunnel connection",
9293 ),
93− ).toBe("warning");
94− expect(
94+ "warning",
95+ );
96+ assert.equal(
9597 ManagedEndpointRuntime.classifyRelayClientOutput(
9698 "2026-06-17T02:00:00Z INF Starting metrics server",
9799 ),
98− ).toBe("debug");
100+ "debug",
101+ );
99102 });
100103
101104 it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () =>
@@ -123,41 +126,65 @@ describe("CloudManagedEndpointRuntime", () => { · 21 unmodified lines
123126 );
124127 const runtime = yield* buildCloudManagedEndpointRuntime(spawner);
125128
126− yield* runtime.applyConfig({
127− providerKind: "cloudflare_tunnel",
128− connectorToken: "token-1",
129− tunnelId: "tunnel-1",
130− tunnelName: "t3-code-env-1",
131− });
132− yield* runtime.applyConfig({
133− providerKind: "cloudflare_tunnel",
134− connectorToken: "token-1",
135− tunnelId: "tunnel-1",
136− tunnelName: "t3-code-env-1",
137− });
138− yield* runtime.applyConfig({
139− providerKind: "cloudflare_tunnel",
140− connectorToken: "token-2",
141− tunnelId: "tunnel-1",
142− tunnelName: "t3-code-env-1",
143− });
144− const stopped = yield* runtime.applyConfig(null);
129+ yield* runtime.applyConfig(
130+ Option.some({
131+ providerKind: "cloudflare_tunnel",
132+ connectorToken: "token-1",
133+ tunnelId: "tunnel-1",
134+ tunnelName: "t3-code-env-1",
135+ }),
136+ );
137+ yield* runtime.applyConfig(
138+ Option.some({
139+ providerKind: "cloudflare_tunnel",
140+ connectorToken: "token-1",
141+ tunnelId: "tunnel-1",
142+ tunnelName: "t3-code-env-1",
143+ }),
144+ );
145+ yield* runtime.applyConfig(
146+ Option.some({
147+ providerKind: "cloudflare_tunnel",
148+ connectorToken: "token-2",
149+ tunnelId: "tunnel-1",
150+ tunnelName: "t3-code-env-1",
151+ }),
152+ );
153+ const stopped = yield* runtime.applyConfig(Option.none());
145154
146− expect(spawned.map((command) => command.command)).toEqual(["cloudflared", "cloudflared"]);
147− expect(spawned.map((command) => command.args)).toEqual([
148− ["tunnel", "run"],
149− ["tunnel", "run"],
150− ]);
151− expect(spawned.map((command) => command.options.env?.TUNNEL_TOKEN)).toEqual([
152− "token-1",
153− "token-2",
154− ]);
155− expect(spawned.map((command) => command.options.stdout)).toEqual(["pipe", "pipe"]);
156− expect(spawned.map((command) => command.options.stderr)).toEqual(["pipe", "pipe"]);
157− expect(spawned.map((command) => command.options.detached)).toEqual([false, false]);
158− expect(spawned.map((command) => command.options.shell)).toEqual([false, false]);
159− expect(killed).toEqual([100, 101]);
160− expect(stopped).toEqual({ status: "disabled" });
155+ assert.deepEqual(
156+ spawned.map((command) => command.command),
157+ ["cloudflared", "cloudflared"],
158+ );
159+ assert.deepEqual(
160+ spawned.map((command) => command.args),
161+ [
162+ ["tunnel", "run"],
163+ ["tunnel", "run"],
164+ ],
165+ );
166+ assert.deepEqual(
167+ spawned.map((command) => command.options.env?.TUNNEL_TOKEN),
168+ ["token-1", "token-2"],
169+ );
170+ assert.deepEqual(
171+ spawned.map((command) => command.options.stdout),
172+ ["pipe", "pipe"],
173+ );
174+ assert.deepEqual(
175+ spawned.map((command) => command.options.stderr),
176+ ["pipe", "pipe"],
177+ );
178+ assert.deepEqual(
179+ spawned.map((command) => command.options.detached),
180+ [false, false],
181+ );
182+ assert.deepEqual(
183+ spawned.map((command) => command.options.shell),
184+ [false, false],
185+ );
186+ assert.deepEqual(killed, [100, 101]);
187+ assert.deepEqual(stopped, { status: "disabled" });
161188 }),
162189 );
163190
@@ -178,18 +205,22 @@ describe("CloudManagedEndpointRuntime", () => { · 14 unmodified lines
178205 );
179206 const runtime = yield* buildCloudManagedEndpointRuntime(spawner);
180207
181− const started = yield* runtime.applyConfig({
182− providerKind: "cloudflare_tunnel",
183− connectorToken: "token",
184− });
185− const unsupported = yield* runtime.applyConfig({
186− providerKind: "manual",
187− connectorToken: "manual-token",
188− });
208+ const started = yield* runtime.applyConfig(
209+ Option.some({
210+ providerKind: "cloudflare_tunnel",
211+ connectorToken: "token",
212+ }),
213+ );
214+ const unsupported = yield* runtime.applyConfig(
215+ Option.some({
216+ providerKind: "manual",
217+ connectorToken: "manual-token",
218+ }),
219+ );
189220
190− expect(started.status).toBe("running");
191− expect(unsupported).toEqual({ status: "unsupported", providerKind: "manual" });
192− expect(killed).toEqual([200]);
221+ assert.equal(started.status, "running");
222+ assert.deepEqual(unsupported, { status: "unsupported", providerKind: "manual" });
223+ assert.deepEqual(killed, [200]);
193224 }),
194225 );
195226
@@ -220,14 +251,20 @@ describe("CloudManagedEndpointRuntime", () => { · 24 unmodified lines
220251 tunnelId: "tunnel-1",
221252 };
222253
223− const first = yield* runtime.applyConfig(config);
254+ const first = yield* runtime.applyConfig(Option.some(config));
224255 firstRunning = false;
225− const second = yield* runtime.applyConfig(config);
256+ const second = yield* runtime.applyConfig(Option.some(config));
226257
227− expect(first).toMatchObject({ status: "running", pid: 300 });
228− expect(second).toMatchObject({ status: "running", pid: 301 });
229− expect(spawned).toEqual([300, 301]);
230− expect(killed).toEqual([300]);
258+ if (first.status !== "running") {
259+ assert.fail(`Expected first connector to be running, got ${first.status}`);
260+ }
261+ assert.equal(first.pid, 300);
262+ if (second.status !== "running") {
263+ assert.fail(`Expected second connector to be running, got ${second.status}`);
264+ }
265+ assert.equal(second.pid, 301);
266+ assert.deepEqual(spawned, [300, 301]);
267+ assert.deepEqual(killed, [300]);
231268 }),
232269 );
233270
@@ -260,17 +297,22 @@ describe("CloudManagedEndpointRuntime", () => { · 26 unmodified lines
260297 );
261298 const runtime = yield* buildCloudManagedEndpointRuntime(spawner);
262299
263− const started = yield* runtime.applyConfig({
264− providerKind: "cloudflare_tunnel",
265− connectorToken: "token",
266− tunnelId: "tunnel-1",
267− });
300+ const started = yield* runtime.applyConfig(
301+ Option.some({
302+ providerKind: "cloudflare_tunnel",
303+ connectorToken: "token",
304+ tunnelId: "tunnel-1",
305+ }),
306+ );
268307 yield* Deferred.succeed(firstExit, ChildProcessSpawner.ExitCode(1));
269308 yield* Deferred.await(secondSpawned);
270309
271− expect(started).toMatchObject({ status: "running", pid: 400 });
272− expect(spawned).toEqual([400, 401]);
273− expect(killed).toEqual([400]);
310+ if (started.status !== "running") {
311+ assert.fail(`Expected connector to be running, got ${started.status}`);
312+ }
313+ assert.equal(started.pid, 400);
314+ assert.deepEqual(spawned, [400, 401]);
315+ assert.deepEqual(killed, [400]);
274316 }),
275317 );
276318
@@ -301,26 +343,33 @@ describe("CloudManagedEndpointRuntime", () => { · 24 unmodified lines
301343 const runtime = yield* buildCloudManagedEndpointRuntime(spawner);
302344
303345 const first = yield* runtime
304− .applyConfig({
305− providerKind: "cloudflare_tunnel",
306− connectorToken: "token-1",
307− })
346+ .applyConfig(
347+ Option.some({
348+ providerKind: "cloudflare_tunnel",
349+ connectorToken: "token-1",
350+ }),
351+ )
308352 .pipe(Effect.forkChild);
309353 yield* Deferred.await(firstSpawnEntered);
310354 const second = yield* runtime
311− .applyConfig({
312− providerKind: "cloudflare_tunnel",
313− connectorToken: "token-2",
314− })
355+ .applyConfig(
356+ Option.some({
357+ providerKind: "cloudflare_tunnel",
358+ connectorToken: "token-2",
359+ }),
360+ )
315361 .pipe(Effect.forkChild);
316362 yield* Deferred.succeed(releaseFirstSpawn, undefined);
317363
318364 yield* Fiber.join(first);
319365 const status = yield* Fiber.join(second);
320366
321− expect(status).toMatchObject({ status: "running", pid: 501 });
322− expect(spawned).toEqual([500, 501]);
323− expect(killed).toEqual([500]);
367+ if (status.status !== "running") {
368+ assert.fail(`Expected connector to be running, got ${status.status}`);
369+ }
370+ assert.equal(status.pid, 501);
371+ assert.deepEqual(spawned, [500, 501]);
372+ assert.deepEqual(killed, [500]);
324373 }),
325374 );
326375
@@ -338,17 +387,19 @@ describe("CloudManagedEndpointRuntime", () => { · 11 unmodified lines
338387 );
339388 const runtime = yield* buildCloudManagedEndpointRuntime(spawner);
340389
341− const status = yield* runtime.applyConfig({
342− providerKind: "cloudflare_tunnel",
343− connectorToken: "token",
344− tunnelId: "tunnel-1",
345− });
390+ const status = yield* runtime.applyConfig(
391+ Option.some({
392+ providerKind: "cloudflare_tunnel",
393+ connectorToken: "token",
394+ tunnelId: "tunnel-1",
395+ }),
396+ );
346397
347− expect(status).toMatchObject({
348− status: "failed",
349− providerKind: "cloudflare_tunnel",
350− tunnelId: "tunnel-1",
351− });
398+ if (status.status !== "failed") {
399+ assert.fail(`Expected connector spawn to fail, got ${status.status}`);
400+ }
401+ assert.equal(status.providerKind, "cloudflare_tunnel");
402+ assert.equal(status.tunnelId, "tunnel-1");
352403 }),
353404 );
354405
@@ -371,17 +422,19 @@ describe("CloudManagedEndpointRuntime", () => { · 16 unmodified lines
371422 ),
372423 );
373424
374− const status = yield* runtime.applyConfig({
375− providerKind: "cloudflare_tunnel",
376− connectorToken: "token",
377− });
425+ const status = yield* runtime.applyConfig(
426+ Option.some({
427+ providerKind: "cloudflare_tunnel",
428+ connectorToken: "token",
429+ }),
430+ );
378431
379− expect(status).toEqual({
432+ assert.deepEqual(status, {
380433 status: "failed",
381434 providerKind: "cloudflare_tunnel",
382435 reason: "The relay client is not installed.",
383436 });
384− expect(spawn).not.toHaveBeenCalled();
437+ assert.equal(spawn.mock.calls.length, 0);
385438 }),
386439 );
387440 });
apps/server/src/cloud/ManagedEndpointRuntime.ts+132−117
@@ -24,9 +24,9 @@ const readRuntimeConfig = Effect.gen(function* () { · 23 unmodified lines
2424 const secrets = yield* ServerSecretStore.ServerSecretStore;
2525 const bytes = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG);
2626 if (Option.isNone(bytes)) {
27− return null;
27+ return Option.none<RelayManagedEndpointRuntimeConfig>();
2828 }
29− return Option.getOrNull(decodeRuntimeConfig(bytesToString(bytes.value)));
29+ return decodeRuntimeConfig(bytesToString(bytes.value));
3030 });
3131
3232 export type CloudManagedEndpointRuntimeStatus =
@@ -56,7 +56,7 @@ export class CloudManagedEndpointRuntime extends Context.Service< · 23 unmodified lines
5656 CloudManagedEndpointRuntime,
5757 {
5858 readonly applyConfig: (
59− config: RelayManagedEndpointRuntimeConfig | null,
59+ config: Option.Option<RelayManagedEndpointRuntimeConfig>,
6060 ) => Effect.Effect<CloudManagedEndpointRuntimeStatus>;
6161 }
6262 >()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {}
@@ -84,28 +84,32 @@ function runtimeConfigKey(config: RelayManagedEndpointRuntimeConfig): string { · 21 unmodified lines
8484 });
8585 }
8686
87−const stopConnector = (connector: ActiveConnector | null) =>
88− connector
89− ? Scope.close(connector.scope, Exit.void).pipe(
87+const stopConnector = (connector: Option.Option<ActiveConnector>) =>
88+ Option.match(connector, {
89+ onNone: () => Effect.void,
90+ onSome: (active) =>
91+ Scope.close(active.scope, Exit.void).pipe(
9092 Effect.tap(() =>
9193 Effect.logInfo("Relay client stopped", {
92− pid: Number(connector.child.pid),
94+ pid: Number(active.child.pid),
9395 }),
9496 ),
9597 Effect.ignore,
96− )
97− : Effect.void;
98+ ),
99+ });
98100
99101 export const make = Effect.gen(function* () {
100102 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
101103 const relayClient = yield* RelayClient.RelayClient;
102− const activeRef = yield* Ref.make<ActiveConnector | null>(null);
103− const desiredConfigRef = yield* Ref.make<RelayManagedEndpointRuntimeConfig | null>(null);
104+ const activeRef = yield* Ref.make<Option.Option<ActiveConnector>>(Option.none());
105+ const desiredConfigRef = yield* Ref.make<Option.Option<RelayManagedEndpointRuntimeConfig>>(
106+ Option.none(),
107+ );
104108 const reconcileSemaphore = yield* Semaphore.make(1);
105109 let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"];
106110
107111 const stopActive = Effect.gen(function* () {
108− const active = yield* Ref.getAndSet(activeRef, null);
112+ const active = yield* Ref.getAndSet(activeRef, Option.none());
109113 yield* stopConnector(active);
110114 });
111115
@@ -116,19 +120,20 @@ export const make = Effect.gen(function* () { · 4 unmodified lines
116120 Effect.gen(function* () {
117121 const active = yield* Ref.get(activeRef);
118122 if (
119− active?.child.pid !== connector.child.pid ||
120− active.configKey !== connector.configKey
123+ Option.isNone(active) ||
124+ active.value.child.pid !== connector.child.pid ||
125+ active.value.configKey !== connector.configKey
121126 ) {
122127 return;
123128 }
124− yield* Ref.set(activeRef, null);
125− yield* stopConnector(connector);
129+ yield* Ref.set(activeRef, Option.none());
130+ yield* stopConnector(Option.some(connector));
126131
127132 const desiredConfig = yield* Ref.get(desiredConfigRef);
128133 if (
129− !desiredConfig ||
130− desiredConfig.providerKind !== "cloudflare_tunnel" ||
131− runtimeConfigKey(desiredConfig) !== connector.configKey
134+ Option.isNone(desiredConfig) ||
135+ desiredConfig.value.providerKind !== "cloudflare_tunnel" ||
136+ runtimeConfigKey(desiredConfig.value) !== connector.configKey
132137 ) {
133138 return;
134139 }
@@ -181,120 +186,130 @@ export const make = Effect.gen(function* () { · 46 unmodified lines
181186 ),
182187 );
183188
184− reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(function* (config) {
185− if (!config || config.providerKind !== "cloudflare_tunnel") {
189+ reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(
190+ function* (configOption) {
191+ if (Option.isNone(configOption)) {
192+ yield* stopActive;
193+ return { status: "disabled" };
194+ }
195+
196+ const config = configOption.value;
197+ if (config.providerKind !== "cloudflare_tunnel") {
198+ yield* stopActive;
199+ return { status: "unsupported", providerKind: config.providerKind };
200+ }
201+
202+ const nextConfigKey = runtimeConfigKey(config);
203+ const active = yield* Ref.get(activeRef);
204+ if (Option.isSome(active) && active.value.configKey === nextConfigKey) {
205+ const isRunning = yield* active.value.child.isRunning.pipe(
206+ Effect.orElseSucceed(() => false),
207+ );
208+ if (isRunning) {
209+ return {
210+ status: "running",
211+ providerKind: "cloudflare_tunnel",
212+ pid: Number(active.value.child.pid),
213+ ...(active.value.config.tunnelId ? { tunnelId: active.value.config.tunnelId } : {}),
214+ ...(active.value.config.tunnelName
215+ ? { tunnelName: active.value.config.tunnelName }
216+ : {}),
217+ } satisfies CloudManagedEndpointRuntimeStatus;
218+ }
219+ }
220+
186221 yield* stopActive;
187− return config
188− ? { status: "unsupported", providerKind: config.providerKind }
189− : { status: "disabled" };
190− }
191222
192− const nextConfigKey = runtimeConfigKey(config);
193− const active = yield* Ref.get(activeRef);
194− if (active?.configKey === nextConfigKey) {
195− const isRunning = yield* active.child.isRunning.pipe(Effect.orElseSucceed(() => false));
196− if (isRunning) {
223+ const executable = yield* relayClient.resolve;
224+ if (executable.status !== "available") {
197225 return {
198− status: "running",
226+ status: "failed",
199227 providerKind: "cloudflare_tunnel",
200− pid: Number(active.child.pid),
201− ...(active.config.tunnelId ? { tunnelId: active.config.tunnelId } : {}),
202− ...(active.config.tunnelName ? { tunnelName: active.config.tunnelName } : {}),
228+ reason:
229+ executable.status === "unsupported"
230+ ? `Relay client is unsupported on ${executable.platform}-${executable.arch}.`
231+ : "The relay client is not installed.",
232+ ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}),
233+ ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}),
203234 } satisfies CloudManagedEndpointRuntimeStatus;
204235 }
205− }
206236
207− yield* stopActive;
208−
209− const executable = yield* relayClient.resolve;
210− if (executable.status !== "available") {
211− return {
212− status: "failed",
213− providerKind: "cloudflare_tunnel",
214− reason:
215− executable.status === "unsupported"
216− ? `Relay client is unsupported on ${executable.platform}-${executable.arch}.`
217− : "The relay client is not installed.",
218− ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}),
219− ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}),
220− } satisfies CloudManagedEndpointRuntimeStatus;
221− }
222−
223− const connectorScope = yield* Scope.make("sequential");
224− const child = yield* spawner
225− .spawn(
226− ChildProcess.make(executable.executablePath, ["tunnel", "run"], {
227− detached: false,
228− env: {
229− ...process.env,
230− TUNNEL_TOKEN: config.connectorToken,
231− },
232− shell: false,
233− stderr: "pipe",
234− stdout: "pipe",
235− }),
236− )
237− .pipe(
238− Effect.provideService(Scope.Scope, connectorScope),
239− Effect.tap((child) =>
240− Effect.logInfo("Relay client process started; waiting for tunnel connection", {
241− pid: Number(child.pid),
242− tunnelId: config.tunnelId,
243− tunnelName: config.tunnelName,
237+ const connectorScope = yield* Scope.make("sequential");
238+ const child = yield* spawner
239+ .spawn(
240+ ChildProcess.make(executable.executablePath, ["tunnel", "run"], {
241+ detached: false,
242+ env: {
243+ ...process.env,
244+ TUNNEL_TOKEN: config.connectorToken,
245+ },
246+ shell: false,
247+ stderr: "pipe",
248+ stdout: "pipe",
244249 }),
245− ),
246− Effect.catch((cause) =>
247− Effect.logWarning("Failed to start relay client", {
248− cause,
249− tunnelId: config.tunnelId,
250− tunnelName: config.tunnelName,
251− }).pipe(
252− Effect.andThen(Scope.close(connectorScope, Exit.void).pipe(Effect.ignore)),
253− Effect.as({
254− status: "failed",
255− providerKind: "cloudflare_tunnel",
256− reason: String(cause),
257− ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}),
258− ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}),
259− } satisfies CloudManagedEndpointRuntimeStatus),
250+ )
251+ .pipe(
252+ Effect.provideService(Scope.Scope, connectorScope),
253+ Effect.tap((child) =>
254+ Effect.logInfo("Relay client process started; waiting for tunnel connection", {
255+ pid: Number(child.pid),
256+ tunnelId: config.tunnelId,
257+ tunnelName: config.tunnelName,
258+ }),
260259 ),
261− ),
262− );
260+ Effect.catch((cause) =>
261+ Effect.logWarning("Failed to start relay client", {
262+ cause,
263+ tunnelId: config.tunnelId,
264+ tunnelName: config.tunnelName,
265+ }).pipe(
266+ Effect.andThen(Scope.close(connectorScope, Exit.void).pipe(Effect.ignore)),
267+ Effect.as({
268+ status: "failed",
269+ providerKind: "cloudflare_tunnel",
270+ reason: String(cause),
271+ ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}),
272+ ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}),
273+ } satisfies CloudManagedEndpointRuntimeStatus),
274+ ),
275+ ),
276+ );
263277
264− if ("status" in child && child.status === "failed") {
265− return child;
266− }
278+ if ("status" in child && child.status === "failed") {
279+ return child;
280+ }
281+
282+ if (!("status" in child)) {
283+ const connector = {
284+ child,
285+ scope: connectorScope,
286+ configKey: nextConfigKey,
287+ config,
288+ } satisfies ActiveConnector;
289+ yield* Ref.set(activeRef, Option.some(connector));
290+ yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope);
291+ yield* Effect.forkIn(superviseConnector(connector), connectorScope);
292+ return {
293+ status: "running",
294+ providerKind: "cloudflare_tunnel",
295+ pid: Number(child.pid),
296+ ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}),
297+ ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}),
298+ } satisfies CloudManagedEndpointRuntimeStatus;
299+ }
267300
268− if (!("status" in child)) {
269− const connector = {
270− child,
271− scope: connectorScope,
272− configKey: nextConfigKey,
273− config,
274− } satisfies ActiveConnector;
275− yield* Ref.set(activeRef, connector);
276− yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope);
277− yield* Effect.forkIn(superviseConnector(connector), connectorScope);
278301 return {
279− status: "running",
302+ status: "failed",
280303 providerKind: "cloudflare_tunnel",
281− pid: Number(child.pid),
304+ reason: "Relay client did not start.",
282305 ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}),
283306 ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}),
284307 } satisfies CloudManagedEndpointRuntimeStatus;
285− }
286−
287− return {
288− status: "failed",
289− providerKind: "cloudflare_tunnel",
290− reason: "Relay client did not start.",
291− ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}),
292− ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}),
293− } satisfies CloudManagedEndpointRuntimeStatus;
294− });
308+ },
309+ );
295310
296311 const applyConfig = Effect.fn("CloudManagedEndpointRuntime.applyConfig")(
297− (config: RelayManagedEndpointRuntimeConfig | null) =>
312+ (config: Option.Option<RelayManagedEndpointRuntimeConfig>) =>
298313 reconcileSemaphore.withPermits(1)(
299314 Ref.set(desiredConfigRef, config).pipe(Effect.andThen(reconcileConfig(config))),
300315 ),
@@ -307,12 +322,12 @@ export const make = Effect.gen(function* () { · 6 unmodified lines
307322 const initialConfig = yield* readRuntimeConfig.pipe(
308323 Effect.catch((cause) =>
309324 Effect.logWarning("Failed to read managed endpoint runtime config", { cause }).pipe(
310− Effect.as(null),
325+ Effect.as(Option.none<RelayManagedEndpointRuntimeConfig>()),
311326 ),
312327 ),
313328 );
314329 yield* runtime.applyConfig(initialConfig);
315− yield* Effect.addFinalizer(() => runtime.applyConfig(null));
330+ yield* Effect.addFinalizer(() => runtime.applyConfig(Option.none()));
316331 return runtime;
317332 });
318333
apps/server/src/cloud/http.ts+2−2
@@ -433,7 +433,7 @@ const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(fu · 432 unmodified lines
433433 });
434434 yield* validateCloudMintPublicKey(payload.cloudMintPublicKey);
435435 const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig(
436− payload.endpointRuntime,
436+ Option.fromNullOr(payload.endpointRuntime),
437437 );
438438 const ok =
439439 endpointRuntimeStatus.status === "disabled" || endpointRuntimeStatus.status === "running";
@@ -642,7 +642,7 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( · 202 unmodified lines
642642 const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")(
643643 function* (dependencies: CloudHttpDependencies) {
644644 yield* requireEnvironmentScope(AuthRelayWriteScope);
645− const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig(null);
645+ const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig(Option.none());
646646 yield* Effect.all(
647647 [
648648 dependencies.secrets.remove(CLOUD_LINKED_USER_ID),
apps/server/src/server.test.ts+5−4
@@ -2253,16 +2253,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 2252 unmodified lines
22532253 layers: {
22542254 cloudManagedEndpointRuntime: {
22552255 applyConfig: (config) => {
2256− appliedRuntimeConfigs.push(config);
2257− if (!config) {
2256+ appliedRuntimeConfigs.push(Option.getOrNull(config));
2257+ if (Option.isNone(config)) {
22582258 return Effect.succeed({ status: "disabled" });
22592259 }
2260+ const runtimeConfig = config.value;
22602261 return Effect.succeed({
22612262 status: "running",
22622263 providerKind: "cloudflare_tunnel",
22632264 pid: 123,
2264− ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}),
2265− ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}),
2265+ ...(runtimeConfig.tunnelId ? { tunnelId: runtimeConfig.tunnelId } : {}),
2266+ ...(runtimeConfig.tunnelName ? { tunnelName: runtimeConfig.tunnelName } : {}),
22662267 });
22672268 },
22682269 },