diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index aac4beb7e32..281499ce913 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -41,8 +41,7 @@ describe("VcsProjectConfig", () => { yield* fileSystem.makeDirectory(nested, { recursive: true }); yield* fileSystem.writeFileString( path.join(configDir, "vcs.json"), - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ vcs: { kind: "jj" } }), + '{"vcs":{"kind":"jj"}}', ); const config = yield* VcsProjectConfig.VcsProjectConfig; @@ -67,4 +66,24 @@ describe("VcsProjectConfig", () => { }), ); }); + + it.layer(TestLayer)("falls back to auto when config JSON is invalid", (it) => { + it.effect("returns auto", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(configDir, "vcs.json"), "{invalid-json"); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + }), + ); + }); }); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index 3e5ee2347ce..200a009f75f 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -2,6 +2,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -15,16 +16,9 @@ const ProjectVcsConfig = Schema.Struct({ ), vcsKind: Schema.optional(VcsDriverKind), }); -const isProjectVcsConfig = Schema.is(ProjectVcsConfig); +const decodeProjectVcsConfig = Schema.decodeUnknownOption(Schema.fromJsonString(ProjectVcsConfig)); -interface ProjectVcsConfigFile { - readonly vcs?: - | { - readonly kind?: VcsDriverKindType | undefined; - } - | undefined; - readonly vcsKind?: VcsDriverKindType | undefined; -} +type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type; export interface VcsProjectConfigResolveInput { readonly cwd: string; @@ -45,14 +39,8 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto return config.vcs?.kind ?? config.vcsKind ?? "auto"; } -function parseConfig(raw: string): ProjectVcsConfigFile | null { - try { - const parsed = JSON.parse(raw) as unknown; - return isProjectVcsConfig(parsed) ? parsed : null; - } catch { - return null; - } -} +const parseConfig = (raw: string): Option.Option => + decodeProjectVcsConfig(raw); export const make = Effect.fn("makeVcsProjectConfig")(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -63,12 +51,12 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { while (true) { const candidate = path.join(current, ".t3code", "vcs.json"); if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { - return candidate; + return Option.some(candidate); } const parent = path.dirname(current); if (parent === current) { - return null; + return Option.none(); } current = parent; } @@ -78,26 +66,25 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { configPath: string, ) { const raw = yield* fileSystem.readFileString(configPath).pipe( + Effect.map(Option.some), Effect.catch((error) => Effect.logWarning("failed to read VCS project config", { configPath, error, - }).pipe(Effect.as(null)), + }).pipe(Effect.as(Option.none())), ), ); - if (raw === null) { - return "auto" as const; - } + if (Option.isNone(raw)) return "auto" as const; - const parsed = parseConfig(raw); - if (parsed === null) { + const parsed = parseConfig(raw.value); + if (Option.isNone(parsed)) { yield* Effect.logWarning("invalid VCS project config", { configPath, }); return "auto" as const; } - return configuredKind(parsed); + return configuredKind(parsed.value); }); const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn( @@ -108,11 +95,9 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { } const configPath = yield* findConfigPath(input.cwd); - if (configPath === null) { - return "auto"; - } + if (Option.isNone(configPath)) return "auto"; - return yield* readConfiguredKind(configPath); + return yield* readConfiguredKind(configPath.value); }); return VcsProjectConfig.of({ diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 80e684d8611..a2e7ce86963 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -242,9 +242,9 @@ describe("ssh tunnel scripts", () => { Effect.result( waitForHttpReady({ baseUrl: "http://127.0.0.1:41773/", - timeoutMs: 1_000, - intervalMs: 100, - probeTimeoutMs: 250, + timeout: Duration.seconds(1), + interval: Duration.millis(100), + probeTimeout: Duration.millis(250), }), ), ); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 5ee5c684779..e075a2c251f 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -50,11 +50,13 @@ import { export const DEFAULT_REMOTE_PORT = 3773; const REMOTE_PORT_SCAN_WINDOW = 200; -const SSH_READY_TIMEOUT_MS = 20_000; -const SSH_READY_PROBE_TIMEOUT_MS = 1_000; -const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; -const REMOTE_READY_TIMEOUT_MS = 15_000; -const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; +const DEFAULT_HTTP_READY_TIMEOUT = Duration.seconds(30); +const DEFAULT_HTTP_READY_INTERVAL = Duration.millis(100); +const SSH_READY_TIMEOUT = Duration.seconds(20); +const SSH_READY_PROBE_TIMEOUT = Duration.seconds(1); +const TUNNEL_SHUTDOWN_TIMEOUT = Duration.seconds(2); +const REMOTE_READY_TIMEOUT = Duration.seconds(15); +const REMOTE_REUSE_READY_TIMEOUT = Duration.seconds(2); export interface RemoteT3RunnerOptions { readonly packageSpec?: string; @@ -686,9 +688,9 @@ export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { T3_WAIT_READY_SCRIPT: stripTrailingNewlines(REMOTE_WAIT_READY_SCRIPT), T3_DEFAULT_REMOTE_PORT: String(DEFAULT_REMOTE_PORT), T3_REMOTE_PORT_SCAN_WINDOW: String(REMOTE_PORT_SCAN_WINDOW), - T3_READY_TIMEOUT_MS: String(REMOTE_READY_TIMEOUT_MS), - T3_REUSE_READY_TIMEOUT_MS: String(REMOTE_REUSE_READY_TIMEOUT_MS), - T3_READY_PROBE_TIMEOUT_MS: String(SSH_READY_PROBE_TIMEOUT_MS), + T3_READY_TIMEOUT_MS: String(Duration.toMillis(REMOTE_READY_TIMEOUT)), + T3_REUSE_READY_TIMEOUT_MS: String(Duration.toMillis(REMOTE_REUSE_READY_TIMEOUT)), + T3_READY_PROBE_TIMEOUT_MS: String(Duration.toMillis(SSH_READY_PROBE_TIMEOUT)), }); } @@ -870,17 +872,18 @@ const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")( export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(function* (input: { readonly baseUrl: string; - readonly timeoutMs?: number; - readonly intervalMs?: number; - readonly probeTimeoutMs?: number; + readonly timeout?: Duration.Duration; + readonly interval?: Duration.Duration; + readonly probeTimeout?: Duration.Duration; readonly path?: string; }): Effect.fn.Return { - const timeoutMs = input.timeoutMs ?? 30_000; - const intervalMs = input.intervalMs ?? 100; - const probeTimeoutMs = input.probeTimeoutMs ?? SSH_READY_PROBE_TIMEOUT_MS; - const retryPolicy = Schedule.spaced(Duration.millis(intervalMs)).pipe( - Schedule.take(Math.max(0, Math.ceil(timeoutMs / intervalMs))), - ); + const timeout = input.timeout ?? DEFAULT_HTTP_READY_TIMEOUT; + const interval = input.interval ?? DEFAULT_HTTP_READY_INTERVAL; + const probeTimeout = input.probeTimeout ?? SSH_READY_PROBE_TIMEOUT; + const timeoutMs = Duration.toMillis(timeout); + const intervalMs = Duration.toMillis(interval); + const probeTimeoutMs = Duration.toMillis(probeTimeout); + const retryPolicy = Schedule.spaced(interval); const requestUrl = new URL(input.path ?? "/", input.baseUrl).toString(); const client = yield* HttpClient.HttpClient; const lastProbeFailure = yield* Ref.make(null); @@ -900,7 +903,7 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio Effect.gen(function* () { attempt += 1; const responseOption = yield* effect.pipe( - Effect.timeoutOption(Duration.millis(probeTimeoutMs)), + Effect.timeoutOption(probeTimeout), Effect.mapError( (cause) => new SshReadinessError({ @@ -953,7 +956,7 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio cause, }), ), - Effect.timeoutOption(Duration.millis(timeoutMs)), + Effect.timeoutOption(timeout), ); return yield* Option.match(result, { @@ -1236,7 +1239,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: yield* Effect.raceFirst( waitForHttpReady({ baseUrl: input.httpBaseUrl, - timeoutMs: SSH_READY_TIMEOUT_MS, + timeout: SSH_READY_TIMEOUT, }), exitFailure, ).pipe( @@ -1296,7 +1299,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: : child .kill({ killSignal: "SIGTERM", - forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, + forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT, }) .pipe(Effect.ignore), ), @@ -1540,7 +1543,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma [ tunnelEntry.process.kill({ killSignal: "SIGTERM", - forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, + forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT, }), stopRemoteServer( tunnelEntry.target, @@ -1594,7 +1597,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma remotePort: entry.remotePort, }); const readinessExit = yield* Effect.exit( - waitForHttpReady({ baseUrl: entry.httpBaseUrl, timeoutMs: 2_000 }), + waitForHttpReady({ baseUrl: entry.httpBaseUrl, timeout: REMOTE_REUSE_READY_TIMEOUT }), ); if (Exit.isSuccess(readinessExit)) { yield* Effect.logDebug("ssh.environment.tunnel.reused", {