Skip to content

Commit a4446e2

Browse files
cursor[bot]cursoragentjuliusmarmingecodex
authored
Improve idiomatic Effect usage in config and Tailscale paths (#3073)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com> Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 494350c commit a4446e2

4 files changed

Lines changed: 115 additions & 39 deletions

File tree

apps/server/src/vcs/VcsProjectConfig.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,47 @@ describe("VcsProjectConfig", () => {
6767
}),
6868
);
6969
});
70+
71+
it.layer(TestLayer)("falls back to auto when config JSON is malformed", (it) => {
72+
it.effect("returns auto", () =>
73+
Effect.gen(function* () {
74+
const fileSystem = yield* FileSystem.FileSystem;
75+
const path = yield* Path.Path;
76+
const root = yield* fileSystem.makeTempDirectoryScoped({
77+
prefix: "t3-vcs-config-test-",
78+
});
79+
const configDir = path.join(root, ".t3code");
80+
yield* fileSystem.makeDirectory(configDir, { recursive: true });
81+
yield* fileSystem.writeFileString(path.join(configDir, "vcs.json"), "{not json");
82+
83+
const config = yield* VcsProjectConfig.VcsProjectConfig;
84+
const kind = yield* config.resolveKind({ cwd: root });
85+
86+
assert.equal(kind, "auto");
87+
}),
88+
);
89+
});
90+
91+
it.layer(TestLayer)("falls back to auto when config kind is invalid", (it) => {
92+
it.effect("returns auto", () =>
93+
Effect.gen(function* () {
94+
const fileSystem = yield* FileSystem.FileSystem;
95+
const path = yield* Path.Path;
96+
const root = yield* fileSystem.makeTempDirectoryScoped({
97+
prefix: "t3-vcs-config-test-",
98+
});
99+
const configDir = path.join(root, ".t3code");
100+
yield* fileSystem.makeDirectory(configDir, { recursive: true });
101+
yield* fileSystem.writeFileString(
102+
path.join(configDir, "vcs.json"),
103+
`{"vcs":{"kind":"svn"}}`,
104+
);
105+
106+
const config = yield* VcsProjectConfig.VcsProjectConfig;
107+
const kind = yield* config.resolveKind({ cwd: root });
108+
109+
assert.equal(kind, "auto");
110+
}),
111+
);
112+
});
70113
});

apps/server/src/vcs/VcsProjectConfig.ts

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import * as Context from "effect/Context";
22
import * as Effect from "effect/Effect";
33
import * as FileSystem from "effect/FileSystem";
44
import * as Layer from "effect/Layer";
5+
import * as Option from "effect/Option";
56
import * as Path from "effect/Path";
67
import * as Schema from "effect/Schema";
78

89
import { VcsDriverKind, type VcsDriverKind as VcsDriverKindType } from "@t3tools/contracts";
10+
import { fromLenientJson } from "@t3tools/shared/schemaJson";
911

1012
const ProjectVcsConfig = Schema.Struct({
1113
vcs: Schema.optional(
@@ -15,16 +17,10 @@ const ProjectVcsConfig = Schema.Struct({
1517
),
1618
vcsKind: Schema.optional(VcsDriverKind),
1719
});
18-
const isProjectVcsConfig = Schema.is(ProjectVcsConfig);
20+
const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig);
21+
const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson);
1922

20-
interface ProjectVcsConfigFile {
21-
readonly vcs?:
22-
| {
23-
readonly kind?: VcsDriverKindType | undefined;
24-
}
25-
| undefined;
26-
readonly vcsKind?: VcsDriverKindType | undefined;
27-
}
23+
type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type;
2824

2925
export interface VcsProjectConfigResolveInput {
3026
readonly cwd: string;
@@ -45,14 +41,8 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto
4541
return config.vcs?.kind ?? config.vcsKind ?? "auto";
4642
}
4743

48-
function parseConfig(raw: string): ProjectVcsConfigFile | null {
49-
try {
50-
const parsed = JSON.parse(raw) as unknown;
51-
return isProjectVcsConfig(parsed) ? parsed : null;
52-
} catch {
53-
return null;
54-
}
55-
}
44+
const parseConfig = (raw: string): Option.Option<ProjectVcsConfigFile> =>
45+
decodeProjectVcsConfigJson(raw);
5646

5747
export const make = Effect.fn("makeVcsProjectConfig")(function* () {
5848
const fileSystem = yield* FileSystem.FileSystem;
@@ -63,12 +53,12 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
6353
while (true) {
6454
const candidate = path.join(current, ".t3code", "vcs.json");
6555
if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
66-
return candidate;
56+
return Option.some(candidate);
6757
}
6858

6959
const parent = path.dirname(current);
7060
if (parent === current) {
71-
return null;
61+
return Option.none();
7262
}
7363
current = parent;
7464
}
@@ -78,26 +68,27 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
7868
configPath: string,
7969
) {
8070
const raw = yield* fileSystem.readFileString(configPath).pipe(
71+
Effect.map(Option.some),
8172
Effect.catch((error) =>
8273
Effect.logWarning("failed to read VCS project config", {
8374
configPath,
8475
error,
85-
}).pipe(Effect.as(null)),
76+
}).pipe(Effect.as(Option.none())),
8677
),
8778
);
88-
if (raw === null) {
79+
if (Option.isNone(raw)) {
8980
return "auto" as const;
9081
}
9182

92-
const parsed = parseConfig(raw);
93-
if (parsed === null) {
83+
const parsed = parseConfig(raw.value);
84+
if (Option.isNone(parsed)) {
9485
yield* Effect.logWarning("invalid VCS project config", {
9586
configPath,
9687
});
9788
return "auto" as const;
9889
}
9990

100-
return configuredKind(parsed);
91+
return configuredKind(parsed.value);
10192
});
10293

10394
const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn(
@@ -108,11 +99,10 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
10899
}
109100

110101
const configPath = yield* findConfigPath(input.cwd);
111-
if (configPath === null) {
112-
return "auto";
113-
}
114-
115-
return yield* readConfiguredKind(configPath);
102+
return yield* Option.match(configPath, {
103+
onNone: () => Effect.succeed("auto" as const),
104+
onSome: readConfiguredKind,
105+
});
116106
});
117107

118108
return VcsProjectConfig.of({

packages/tailscale/src/tailscale.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { assert, describe, it } from "@effect/vitest";
22
import * as Effect from "effect/Effect";
3+
import * as Fiber from "effect/Fiber";
34
import * as Layer from "effect/Layer";
45
import * as Sink from "effect/Sink";
56
import * as Stream from "effect/Stream";
7+
import * as TestClock from "effect/testing/TestClock";
68
import { ChildProcessSpawner } from "effect/unstable/process";
79

810
import {
@@ -13,6 +15,7 @@ import {
1315
parseTailscaleMagicDnsName,
1416
parseTailscaleStatus,
1517
readTailscaleStatus,
18+
TAILSCALE_STATUS_TIMEOUT,
1619
} from "./tailscale.ts";
1720

1821
const encoder = new TextEncoder();
@@ -35,6 +38,22 @@ function mockHandle(result: { stdout?: string; stderr?: string; code?: number })
3538
});
3639
}
3740

41+
function neverFinishingMockHandle() {
42+
return ChildProcessSpawner.makeHandle({
43+
pid: ChildProcessSpawner.ProcessId(1),
44+
exitCode: Effect.never,
45+
isRunning: Effect.succeed(true),
46+
kill: () => Effect.void,
47+
unref: Effect.succeed(Effect.void),
48+
stdin: Sink.drain,
49+
stdout: Stream.empty,
50+
stderr: Stream.empty,
51+
all: Stream.empty,
52+
getInputFd: () => Sink.drain,
53+
getOutputFd: () => Stream.empty,
54+
});
55+
}
56+
3857
function mockSpawnerLayer(
3958
handler: (
4059
command: string,
@@ -112,6 +131,29 @@ describe("tailscale", () => {
112131
});
113132
});
114133

134+
it.effect("times out tailscale status through TestClock", () => {
135+
const layer = Layer.merge(
136+
TestClock.layer(),
137+
Layer.succeed(
138+
ChildProcessSpawner.ChildProcessSpawner,
139+
ChildProcessSpawner.make(() => Effect.succeed(neverFinishingMockHandle())),
140+
),
141+
);
142+
143+
return Effect.gen(function* () {
144+
const fiber = yield* readTailscaleStatus.pipe(Effect.flip, Effect.forkScoped);
145+
yield* Effect.yieldNow;
146+
yield* TestClock.adjust(TAILSCALE_STATUS_TIMEOUT);
147+
const error = yield* Fiber.join(fiber);
148+
149+
if (error._tag !== "TailscaleCommandError") {
150+
assert.fail(`Expected TailscaleCommandError, received ${error._tag}.`);
151+
}
152+
assert.equal(error.message, "Tailscale status timed out.");
153+
assert.equal(error.exitCode, null);
154+
}).pipe(Effect.provide(layer));
155+
});
156+
115157
it.effect("configures tailscale serve through the process spawner service", () => {
116158
const layer = mockSpawnerLayer((command, args) => {
117159
assert.equal(command, "tailscale");

packages/tailscale/src/tailscale.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
22
import * as Data from "effect/Data";
3+
import * as Duration from "effect/Duration";
34
import * as Effect from "effect/Effect";
45
import * as Option from "effect/Option";
56
import * as Schema from "effect/Schema";
@@ -8,9 +9,9 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http";
89
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
910

1011
export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
11-
export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500;
12-
export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000;
13-
export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500;
12+
export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500);
13+
export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10);
14+
export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500);
1415

1516
// tailscale is a real executable everywhere (`tailscale.exe` on Windows), so
1617
// it is always spawned directly rather than through cmd.exe shell mode.
@@ -180,7 +181,7 @@ export const readTailscaleStatus: Effect.Effect<
180181
return yield* parseTailscaleStatus(stdout);
181182
}).pipe(
182183
Effect.scoped,
183-
Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT_MS),
184+
Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT),
184185
Effect.flatMap((result) =>
185186
Option.match(result, {
186187
onNone: () =>
@@ -212,7 +213,7 @@ const runTailscaleCommand = (
212213
readonly runMessage: string;
213214
readonly exitMessage: (exitCode: number) => string;
214215
readonly timeoutMessage: string;
215-
readonly timeoutMs: number;
216+
readonly timeout: Duration.Input;
216217
},
217218
): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>
218219
Effect.gen(function* () {
@@ -246,7 +247,7 @@ const runTailscaleCommand = (
246247
}
247248
}).pipe(
248249
Effect.scoped,
249-
Effect.timeoutOption(input.timeoutMs),
250+
Effect.timeoutOption(input.timeout),
250251
Effect.flatMap((result) =>
251252
Option.match(result, {
252253
onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)),
@@ -268,7 +269,7 @@ export const ensureTailscaleServe = (input: {
268269
runMessage: "Failed to run tailscale serve.",
269270
exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`,
270271
timeoutMessage: "Tailscale serve timed out.",
271-
timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,
272+
timeout: TAILSCALE_SERVE_TIMEOUT,
272273
});
273274
};
274275

@@ -284,21 +285,21 @@ export const disableTailscaleServe = (
284285
runMessage: "Failed to run tailscale serve off.",
285286
exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`,
286287
timeoutMessage: "Tailscale serve off timed out.",
287-
timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,
288+
timeout: TAILSCALE_SERVE_TIMEOUT,
288289
});
289290
});
290291

291292
export const probeTailscaleHttpsEndpoint = (input: {
292293
readonly baseUrl: string;
293-
readonly timeoutMs?: number;
294+
readonly timeout?: Duration.Input;
294295
}): Effect.Effect<boolean, never, HttpClient.HttpClient> =>
295296
Effect.gen(function* () {
296297
const client = yield* HttpClient.HttpClient;
297298
const response = yield* Effect.gen(function* () {
298299
const url = new URL("/.well-known/t3/environment", input.baseUrl);
299300
const request = HttpClientRequest.get(url.toString());
300301
return yield* client.execute(request);
301-
}).pipe(Effect.timeoutOption(input.timeoutMs ?? TAILSCALE_PROBE_TIMEOUT_MS));
302+
}).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT));
302303

303304
return Option.match(response, {
304305
onNone: () => false,

0 commit comments

Comments
 (0)