diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index cf6cb7beaa..f326a524f9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1601,7 +1601,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { hasActionableProposedPlan: row.hasActionableProposedPlan > 0, }), ), - updatedAt: updatedAt ?? new Date(0).toISOString(), + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", }; return yield* decodeShellSnapshot(snapshot).pipe( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b0d6ef8973..455cac1eb0 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -27,6 +27,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { deepMerge } from "@t3tools/shared/Struct"; import { createModelCapabilities } from "@t3tools/shared/model"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; @@ -48,6 +49,8 @@ import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.t import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; const decodeServerSettings = Schema.decodeSync(ServerSettings); +const encodeServerSettings = Schema.encodeSync(ServerSettings); +const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); @@ -256,7 +259,8 @@ function makeMutableServerSettingsService( updateSettings: (patch) => Effect.gen(function* () { const current = yield* Ref.get(settingsRef); - const next = decodeServerSettings(deepMerge(current, patch)); + const next = applyServerSettingsPatch(current, patch); + encodeServerSettings(next); yield* Ref.set(settingsRef, next); yield* PubSub.publish(changes, next); return next; @@ -930,7 +934,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T const missingBinary = `t3code_codex_missing_`; const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( - deepMerge(DEFAULT_SERVER_SETTINGS, { + deepMerge(encodedDefaultServerSettings, { providers: { // Disable every built-in probe that would otherwise spawn // on the CI host. `enabled: false` short-circuits each @@ -1029,7 +1033,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T const secondMissing = `t3code_codex_second_`; const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( - deepMerge(DEFAULT_SERVER_SETTINGS, { + deepMerge(encodedDefaultServerSettings, { providers: { codex: { enabled: true, binaryPath: firstMissing }, claudeAgent: { enabled: false }, @@ -1124,7 +1128,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T Effect.gen(function* () { const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( - deepMerge(DEFAULT_SERVER_SETTINGS, { + deepMerge(encodedDefaultServerSettings, { providers: { codex: { enabled: false }, claudeAgent: { enabled: false }, @@ -1180,7 +1184,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T Effect.gen(function* () { const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( - deepMerge(DEFAULT_SERVER_SETTINGS, { + deepMerge(encodedDefaultServerSettings, { providers: { codex: { enabled: false, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 857da7aebe..95a2e4e91a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -635,7 +635,7 @@ const buildAppUnderTest = (options?: { snapshotSequence: 0, projects: [], threads: [], - updatedAt: new Date(0).toISOString(), + updatedAt: "1970-01-01T00:00:00.000Z", }), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getProjectShellById: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 93243b21c6..7af27f0b7c 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -9,6 +9,7 @@ import { import { createModelSelection } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -437,6 +438,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { serverPassword: "secret-password", }, }, + automaticGitFetchInterval: Duration.seconds(10), }); assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); @@ -458,6 +460,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { serverPassword: "secret-password", }, }, + automaticGitFetchInterval: 10_000, }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 46d5615f11..5ea2e03813 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -45,15 +45,33 @@ import * as Semaphore from "effect/Semaphore"; import { writeFileStringAtomically } from "./atomicWrite.ts"; import { ServerConfig } from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; -import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts"; -const decodeServerSettings = Schema.decodeEffect(ServerSettings); + +const encodeServerSettings = Schema.encodeEffect(ServerSettings); +const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); +const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); +const normalizeServerSettings = ( + settings: ServerSettings, +): Effect.Effect => + encodeServerSettings(settings).pipe( + Effect.flatMap(decodeServerSettings), + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath: "", + detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, + cause, + }), + ), + ); + function providerEnvironmentSecretName(input: { readonly instanceId: string; readonly name: string; @@ -117,9 +135,15 @@ export class ServerSettingsService extends Context.Service< Layer.effect( ServerSettingsService, Effect.gen(function* () { - const currentSettingsRef = yield* Ref.make( - deepMerge(DEFAULT_SERVER_SETTINGS, overrides), - ); + const { automaticGitFetchInterval, ...overridesForMerge } = overrides; + const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); + const initialSettings = yield* normalizeServerSettings({ + ...merged, + ...(automaticGitFetchInterval !== undefined + ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration } + : {}), + }); + const currentSettingsRef = yield* Ref.make(initialSettings); return { start: Effect.void, @@ -127,18 +151,8 @@ export class ServerSettingsService extends Context.Service< getSettings: Ref.get(currentSettingsRef), updateSettings: (patch) => Ref.get(currentSettingsRef).pipe( - Effect.flatMap((currentSettings) => - decodeServerSettings(applyServerSettingsPatch(currentSettings, patch)).pipe( - Effect.mapError( - (cause) => - new ServerSettingsError({ - settingsPath: "", - detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, - cause, - }), - ), - ), - ), + Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)), + Effect.flatMap(normalizeServerSettings), Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), ), streamChanges: Stream.empty, @@ -200,7 +214,10 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting } // Values under these keys are compared as a whole — never stripped field-by-field. -const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set(["textGenerationModelSelection"]); +const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ + "automaticGitFetchInterval", + "textGenerationModelSelection", +]); function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { if (Array.isArray(current) || Array.isArray(defaults)) { @@ -430,25 +447,29 @@ const makeServerSettings = Effect.gen(function* () { }; }); - const writeSettingsAtomically = (settings: ServerSettings) => { - const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; + const writeSettingsAtomically = Effect.fnUntraced( + function* (settings: ServerSettings) { + const sparseSettingsJson = yield* encodeServerSettingsJson( + stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, + ); - return writeFileStringAtomically({ - filePath: settingsPath, - contents: `${JSON.stringify(sparseSettings, null, 2)}\n`, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, pathService), - Effect.mapError( - (cause) => - new ServerSettingsError({ - settingsPath, - detail: "failed to write settings file", - cause, - }), - ), - ); - }; + return yield* writeFileStringAtomically({ + filePath: settingsPath, + contents: `${sparseSettingsJson}\n`, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathService), + ); + }, + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to write settings file", + cause, + }), + ), + ); const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { @@ -533,16 +554,7 @@ const makeServerSettings = Effect.gen(function* () { current, applyServerSettingsPatch(current, patch), ); - const next = yield* decodeServerSettings(nextPersisted).pipe( - Effect.mapError( - (cause) => - new ServerSettingsError({ - settingsPath: "", - detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, - cause, - }), - ), - ); + const next = yield* normalizeServerSettings(nextPersisted); yield* writeSettingsAtomically(next); yield* Cache.set(settingsCache, cacheKey, next); yield* emitChange(next); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 558d6ef296..00f40a69aa 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -130,6 +130,65 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("disables SSH askpass for background upstream status fetches", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const tempDir = yield* makeTmpDir("git-vcs-driver-ssh-env-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const sshLogPath = pathService.join(tempDir, "ssh-env.txt"); + const sshWrapperPath = pathService.join(tempDir, "ssh-wrapper.sh"); + const previousGitSsh = process.env.GIT_SSH; + const previousAskpassRequire = process.env.SSH_ASKPASS_REQUIRE; + const previousAskpassLog = process.env.T3_TEST_SSH_ASKPASS_LOG; + + yield* fileSystem.writeFileString( + sshWrapperPath, + [ + "#!/bin/sh", + 'printf "%s\\n" "${SSH_ASKPASS_REQUIRE:-}" > "$T3_TEST_SSH_ASKPASS_LOG"', + "exit 1", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(sshWrapperPath, 0o755); + yield* git(cwd, ["remote", "add", "origin", "ssh://example.invalid/repo.git"]); + yield* git(cwd, ["update-ref", `refs/remotes/origin/${initialBranch}`, "HEAD"]); + yield* git(cwd, ["branch", "--set-upstream-to", `origin/${initialBranch}`]); + + yield* Effect.gen(function* () { + process.env.GIT_SSH = sshWrapperPath; + process.env.SSH_ASKPASS_REQUIRE = "force"; + process.env.T3_TEST_SSH_ASKPASS_LOG = sshLogPath; + + yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal((yield* fileSystem.readFileString(sshLogPath)).trim(), "never"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousGitSsh === undefined) { + delete process.env.GIT_SSH; + } else { + process.env.GIT_SSH = previousGitSsh; + } + if (previousAskpassRequire === undefined) { + delete process.env.SSH_ASKPASS_REQUIRE; + } else { + process.env.SSH_ASKPASS_REQUIRE = previousAskpassRequire; + } + if (previousAskpassLog === undefined) { + delete process.env.T3_TEST_SSH_ASKPASS_LOG; + } else { + process.env.T3_TEST_SSH_ASKPASS_LOG = previousAskpassLog; + } + }), + ), + ); + }), + ); + it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index b8199c33b4..3a05dea633 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -41,6 +41,9 @@ const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; +const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ + SSH_ASKPASS_REQUIRE: "never", +} satisfies NodeJS.ProcessEnv); const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ @@ -72,6 +75,7 @@ interface ExecuteGitOptions { timeoutMs?: number | undefined; allowNonZeroExit?: boolean | undefined; fallbackErrorMessage?: string | undefined; + env?: NodeJS.ProcessEnv | undefined; maxOutputBytes?: number | undefined; truncateOutputAtMaxBytes?: boolean | undefined; progress?: GitVcsDriver.ExecuteGitProgress | undefined; @@ -738,6 +742,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd, args, ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), allowNonZeroExit: true, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), @@ -870,6 +875,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], { allowNonZeroExit: true, + env: STATUS_UPSTREAM_REFRESH_ENV, timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), }, ).pipe(Effect.asVoid); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 90567d437d..ccadf9a921 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,6 +1,7 @@ import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -284,6 +285,31 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("does not start automatic remote refreshes when disabled", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const snapshot = yield* Stream.runHead( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, + ), + ); + + assert.isTrue(Option.isSome(snapshot)); + assert.equal(state.remoteStatusCalls, 0); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 4c431fd007..c93b729173 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -22,7 +22,7 @@ import { mergeGitStatusParts } from "@t3tools/shared/git"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; -const VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); +const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); interface VcsStatusChange { readonly cwd: string; @@ -44,6 +44,10 @@ interface ActiveRemotePoller { readonly subscriberCount: number; } +interface StreamStatusOptions { + readonly automaticRemoteRefreshInterval?: Effect.Effect; +} + export interface VcsStatusBroadcasterShape { readonly getStatus: ( input: VcsStatusInput, @@ -54,6 +58,7 @@ export interface VcsStatusBroadcasterShape { readonly refreshStatus: (cwd: string) => Effect.Effect; readonly streamStatus: ( input: VcsStatusInput, + options?: StreamStatusOptions, ) => Stream.Stream; } @@ -232,19 +237,32 @@ export const layer = Layer.effect( return mergeGitStatusParts(local, remote); }); - const makeRemoteRefreshLoop = (cwd: string) => { - const logRefreshFailure = (error: Error) => + const makeRemoteRefreshLoop = ( + cwd: string, + automaticRemoteRefreshInterval: Effect.Effect, + ) => { + const logRefreshFailure = (error: GitManagerServiceError) => Effect.logWarning("VCS remote status refresh failed", { cwd, detail: error.message, }); + const refreshRemoteStatusIfEnabled = automaticRemoteRefreshInterval.pipe( + Effect.flatMap((interval) => + Duration.isZero(interval) ? Effect.void : refreshRemoteStatus(cwd).pipe(Effect.asVoid), + ), + ); + const sleepForConfiguredInterval = automaticRemoteRefreshInterval.pipe( + Effect.flatMap((interval) => + Effect.sleep(Duration.isZero(interval) ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL : interval), + ), + ); - return refreshRemoteStatus(cwd).pipe( + return refreshRemoteStatusIfEnabled.pipe( Effect.catch(logRefreshFailure), Effect.andThen( Effect.forever( - Effect.sleep(VCS_STATUS_REFRESH_INTERVAL).pipe( - Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))), + sleepForConfiguredInterval.pipe( + Effect.andThen(refreshRemoteStatusIfEnabled.pipe(Effect.catch(logRefreshFailure))), ), ), ), @@ -253,6 +271,7 @@ export const layer = Layer.effect( const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( cwd: string, + automaticRemoteRefreshInterval: Effect.Effect, ) { yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { const existing = activePollers.get(cwd); @@ -265,7 +284,7 @@ export const layer = Layer.effect( return Effect.succeed([undefined, nextPollers] as const); } - return makeRemoteRefreshLoop(cwd).pipe( + return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval).pipe( Effect.forkIn(broadcasterScope), Effect.map((fiber) => { const nextPollers = new Map(activePollers); @@ -307,14 +326,18 @@ export const layer = Layer.effect( } }); - const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input) => + const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input, options) => Stream.unwrap( Effect.gen(function* () { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); const subscription = yield* PubSub.subscribe(changesPubSub); const initialLocal = yield* getOrLoadLocalStatus(cwd); const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null; - yield* retainRemotePoller(cwd); + yield* retainRemotePoller( + cwd, + options?.automaticRemoteRefreshInterval ?? + Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), + ); const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d3c891b2bb..001c9baff9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -9,6 +9,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { + DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, type AuthAccessStreamEvent, AuthSessionId, CommandId, @@ -180,6 +181,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const serverEnvironment = yield* ServerEnvironment; const serverAuth = yield* ServerAuth; const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; + const automaticGitFetchInterval = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.automaticGitFetchInterval), + Effect.catch((cause) => + Effect.logWarning("Failed to read automatic Git fetch interval setting", { + detail: cause.message, + }).pipe(Effect.as(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), + ), + ); const sourceControlRepositories = yield* SourceControlRepositoryService; const bootstrapCredentials = yield* BootstrapCredentialService; const sessions = yield* SessionCredentialService; @@ -981,7 +990,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => [WS_METHODS.subscribeVcsStatus]: (input) => observeRpcStream( WS_METHODS.subscribeVcsStatus, - vcsStatusBroadcaster.streamStatus(input), + vcsStatusBroadcaster.streamStatus(input, { + automaticRemoteRefreshInterval: automaticGitFetchInterval, + }), { "rpc.aggregate": "vcs", }, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index f51780abf5..bb62d8edbc 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -18,11 +18,13 @@ import { WS_METHODS, OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, + ServerConfig as ServerConfigSchema, } from "@t3tools/contracts"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; import { page } from "vitest/browser"; @@ -106,6 +108,7 @@ const rpcHarness = new BrowserWsRpcHarness(); const wsRequests = rpcHarness.requests; let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null; const wsLink = ws.link(/ws(s)?:\/\/.*/); +const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); interface ViewportSpec { name: string; @@ -955,7 +958,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { } const tag = body._tag; if (tag === WS_METHODS.serverGetConfig) { - return fixture.serverConfig; + return encodeServerConfig(fixture.serverConfig); } if (tag === WS_METHODS.serverDiscoverSourceControl) { return { @@ -1662,7 +1665,7 @@ describe("ChatView timeline estimator parity (full app)", () => { { version: 1, type: "snapshot", - config: fixture.serverConfig, + config: encodeServerConfig(fixture.serverConfig), }, ]; } diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 6bb8be2c25..611eaf572d 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -11,12 +11,15 @@ import { ProviderInstanceId, type ServerConfig, type ServerLifecycleWelcomePayload, + ServerConfig as ServerConfigSchema, + ServerSettings, type ThreadId, WS_METHODS, } from "@t3tools/contracts"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { ws, http, HttpResponse } from "msw"; import { setupWorker } from "msw/browser"; +import * as Schema from "effect/Schema"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -50,6 +53,8 @@ interface TestFixture { let fixture: TestFixture; const rpcHarness = new BrowserWsRpcHarness(); +const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); +const encodeServerSettings = Schema.encodeSync(ServerSettings); const wsLink = ws.link(/ws(s)?:\/\/.*/); @@ -254,7 +259,7 @@ function buildFixture(): TestFixture { function resolveWsRpc(tag: string): unknown { if (tag === WS_METHODS.serverGetConfig) { - return fixture.serverConfig; + return encodeServerConfig(fixture.serverConfig); } if (tag === WS_METHODS.vcsListRefs) { return { @@ -394,7 +399,7 @@ async function waitForServerConfigStreamReady(): Promise { rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { version: 1, type: "settingsUpdated", - payload: { settings: fixture.serverConfig.settings }, + payload: { settings: encodeServerSettings(fixture.serverConfig.settings) }, }); try { @@ -485,7 +490,7 @@ describe("Keybindings update toast", () => { { version: 1, type: "snapshot", - config: fixture.serverConfig, + config: encodeServerConfig(fixture.serverConfig), }, ]; } diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 5dfc1bac04..4abefc5425 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -1256,10 +1256,47 @@ describe("SourceControlSettingsPanel discovery states", () => { , ); - await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); + await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument(); }); + it("shows Git fetch interval settings inside the Git details dropdown", async () => { + setSourceControlDiscoveryStub(async () => ({ + versionControlSystems: [ + { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "available", + version: Option.some("git version 2.50.0"), + installHint: "Install Git.", + detail: Option.none(), + }, + ], + sourceControlProviders: [], + })); + + mounted = await render( + + + , + ); + + const toggle = page.getByRole("button", { name: "Toggle Git details" }); + await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); + + await toggle.click(); + + await expect.element(toggle).toHaveAttribute("aria-expanded", "true"); + await expect + .element(page.getByLabelText("Automatic Git fetch interval in seconds")) + .toBeVisible(); + await expect + .element(page.getByText("Automatic Git fetches run every 30 seconds")) + .not.toBeInTheDocument(); + }); + it("does not rescan on remount while the discovery atom is fresh", async () => { let calls = 0; setSourceControlDiscoveryStub(async () => { @@ -1287,7 +1324,7 @@ describe("SourceControlSettingsPanel discovery states", () => { , ); - await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); + await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); expect(calls).toBe(1); const teardown = mounted.cleanup ?? mounted.unmount; @@ -1301,7 +1338,7 @@ describe("SourceControlSettingsPanel discovery states", () => { , ); - await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); + await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); expect(calls).toBe(1); }); }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index def96b9ba9..505be5c73f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -14,6 +14,7 @@ import { import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; +import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { @@ -407,6 +408,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), + ...(Duration.toMillis(settings.automaticGitFetchInterval) !== + Duration.toMillis(DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval) + ? ["Automatic Git fetch interval"] + : []), ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), @@ -430,6 +435,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.diffIgnoreWhitespace, settings.diffWordWrap, + settings.automaticGitFetchInterval, settings.enableAssistantStreaming, settings.sidebarThreadPreviewCount, settings.timestampFormat, @@ -455,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, + automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 59c6e4aa63..0cda0c1b86 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -1,6 +1,7 @@ -import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; +import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; +import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; -import { type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import type { SourceControlProviderKind, SourceControlDiscoveryResult, @@ -9,7 +10,9 @@ import type { VcsDriverKind, VcsDiscoveryItem, } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { refreshSourceControlDiscovery, @@ -17,6 +20,7 @@ import { } from "../../lib/sourceControlDiscoveryState"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; +import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { Empty, EmptyContent, @@ -26,6 +30,13 @@ import { EmptyTitle, } from "../ui/empty"; import { Skeleton } from "../ui/skeleton"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../ui/number-field"; import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { @@ -38,7 +49,7 @@ import { type Icon, } from "../Icons"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; -import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; +import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout"; const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { versionControlSystems: [], @@ -58,6 +69,18 @@ const VCS_ICONS: Partial> = { }; const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const; +const GIT_FETCH_INTERVAL_STEP_SECONDS = 5; + +function durationToSeconds(duration: Duration.Duration): number { + return Math.round(Duration.toMillis(duration) / 1_000); +} + +function normalizeFetchIntervalSeconds(value: number | null): number { + if (value === null || !Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.round(value)); +} function optionLabel(value: Option.Option): string | null { return Option.getOrNull(value); @@ -146,7 +169,7 @@ function itemSummary({ } if (item.status !== "available") { - return Not available on this server — {item.installHint}; + return Not available on this server: {item.installHint}; } if (auth) { @@ -189,8 +212,10 @@ function itemSummary({ function DiscoveryItemRow({ item, + children, }: { readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; + readonly children?: ReactNode; }) { const version = optionLabel(item.version); const enabled = @@ -198,6 +223,8 @@ function DiscoveryItemRow({ const auth = isProviderDiscoveryItem(item) ? item.auth : null; const authStatus = auth ? authPresentation(auth) : null; const authAccount = auth ? optionLabel(auth.account) : null; + const [isExpanded, setIsExpanded] = useState(false); + const hasDetails = children !== undefined; return (
-

+ {item.label} -

+ {version ? {version} : null} {isVcsNotReady(item) ? ( @@ -231,12 +258,100 @@ function DiscoveryItemRow({

+ {hasDetails ? ( + + ) : null} {!isVcsNotReady(item) ? ( ) : null}
+ + {hasDetails ? ( + + +
{children}
+
+
+ ) : null} + + ); +} + +function GitFetchIntervalSettings() { + const automaticGitFetchInterval = useSettings((settings) => settings.automaticGitFetchInterval); + const { updateSettings } = useUpdateSettings(); + const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval); + const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( + DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, + ); + const canResetFetchInterval = + automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds; + + return ( +
+
+
+
+ Fetch interval + + {canResetFetchInterval ? ( + + updateSettings({ + automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, + }) + } + /> + ) : null} + +
+

+ Refresh remote branch status in the background. Set this to 0 seconds if Git credentials + or security keys should only be prompted by explicit Git actions. +

+
+
+ + updateSettings({ + automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)), + }) + } + > + + + + + + + seconds +
+
); } @@ -324,7 +439,6 @@ function EmptySourceControlDiscovery({ export function SourceControlSettingsPanel() { const discovery = useSourceControlDiscovery(); - const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; const hasDiscoveryItems = result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; @@ -364,7 +478,9 @@ export function SourceControlSettingsPanel() { {result.versionControlSystems.length > 0 ? ( {result.versionControlSystems.map((item) => ( - + + {item.kind === "git" ? : undefined} + ))} ) : null} diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index c978e7690e..1fe91455fc 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -1,9 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; -import { Cause, Effect, Option } from "effect"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; - +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import { readEnvironmentApi } from "../environmentApi"; import { appAtomRegistry } from "../rpc/atomRegistry"; diff --git a/apps/web/src/rpc/wsTransport.test.ts b/apps/web/src/rpc/wsTransport.test.ts index 6e69acbb55..aeb5496f51 100644 --- a/apps/web/src/rpc/wsTransport.test.ts +++ b/apps/web/src/rpc/wsTransport.test.ts @@ -1,4 +1,5 @@ -import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS, ServerSettings, WS_METHODS } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -18,6 +19,8 @@ import { } from "../rpc/wsConnectionState"; import { WsTransport } from "./wsTransport"; +const encodeServerSettings = Schema.encodeSync(ServerSettings); + type WsEventType = "open" | "message" | "close" | "error"; type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; type WsListener = (event?: WsEvent) => void; @@ -1222,7 +1225,7 @@ describe("WsTransport", () => { requestId: requestMessage.id, exit: { _tag: "Success", - value: DEFAULT_SERVER_SETTINGS, + value: encodeServerSettings(DEFAULT_SERVER_SETTINGS), }, }), ); diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 5baf426a8c..614ea5131f 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -1,6 +1,16 @@ +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; -export const TrimmedString = Schema.Trim; +export const TrimmedString = Schema.String.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.succeed(value.trim()), + encode: (value) => Effect.succeed(value.trim()), + }), + ), +); export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()); export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); diff --git a/packages/contracts/src/providerInstance.test.ts b/packages/contracts/src/providerInstance.test.ts index 1a2ffbb995..272e4a70ef 100644 --- a/packages/contracts/src/providerInstance.test.ts +++ b/packages/contracts/src/providerInstance.test.ts @@ -109,6 +109,22 @@ describe("ProviderInstanceConfig", () => { expect(decoded.config).toEqual(opaqueConfig); }); + it("trims provider instance envelope fields", () => { + const decoded = decodeProviderInstanceConfig({ + driver: " codex ", + displayName: " Codex Personal ", + accentColor: " #dc2626 ", + environment: [{ name: " OPENROUTER_API_KEY ", value: " sk-or-test " }], + }); + + expect(decoded).toMatchObject({ + driver: "codex", + displayName: "Codex Personal", + accentColor: "#dc2626", + environment: [{ name: "OPENROUTER_API_KEY", value: " sk-or-test " }], + }); + }); + it("decodes generic environment variables on the instance envelope", () => { const decoded = decodeProviderInstanceConfig({ driver: "claudeAgent", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 8c29282792..39695fe3b0 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -6,6 +6,7 @@ import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./ const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); +const encodeServerSettings = Schema.encodeSync(ServerSettings); describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { @@ -92,3 +93,61 @@ describe("ServerSettingsPatch.providerInstances", () => { expect(patch.providerInstances?.[ollamaId]?.driver).toBe("ollama"); }); }); + +describe("ServerSettingsPatch string normalization", () => { + it("trims string settings while decoding patches", () => { + const patch = decodeServerSettingsPatch({ + addProjectBaseDirectory: " ~/Development ", + textGenerationModelSelection: { model: " gpt-5.4-mini " }, + observability: { + otlpTracesUrl: " http://localhost:4318/v1/traces ", + }, + providers: { + codex: { + binaryPath: " /opt/homebrew/bin/codex ", + homePath: " ~/.codex ", + }, + }, + providerInstances: { + codex_personal: { + driver: " codex ", + displayName: " Codex Personal ", + config: { homePath: " ~/.codex-personal " }, + }, + }, + }); + + expect(patch.addProjectBaseDirectory).toBe("~/Development"); + expect(patch.textGenerationModelSelection?.model).toBe("gpt-5.4-mini"); + expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); + expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); + expect(patch.providers?.codex?.homePath).toBe("~/.codex"); + expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( + "codex", + ); + expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.displayName).toBe( + "Codex Personal", + ); + expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.config).toEqual({ + homePath: " ~/.codex-personal ", + }); + }); + + it("trims encoded server settings values before validation", () => { + const defaultSettings = decodeServerSettings({}); + const encoded = encodeServerSettings({ + ...defaultSettings, + addProjectBaseDirectory: " ~/Development ", + providers: { + ...defaultSettings.providers, + codex: { + ...defaultSettings.providers.codex, + binaryPath: " /opt/homebrew/bin/codex ", + }, + }, + }); + + expect(encoded.addProjectBaseDirectory).toBe("~/Development"); + expect(encoded.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 26a1a171cf..2d115eed98 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; @@ -336,8 +337,15 @@ export const ObservabilitySettings = Schema.Struct({ }); export type ObservabilitySettings = typeof ObservabilitySettings.Type; +export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); + export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + automaticGitFetchInterval: Schema.DurationFromMillis.pipe( + Schema.withDecodingDefault( + Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), + ), + ), defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), @@ -408,45 +416,46 @@ const ModelSelectionPatch = Schema.Struct({ const CodexSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(Schema.String), - homePath: Schema.optionalKey(Schema.String), - shadowHomePath: Schema.optionalKey(Schema.String), + binaryPath: Schema.optionalKey(TrimmedString), + homePath: Schema.optionalKey(TrimmedString), + shadowHomePath: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); const ClaudeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(Schema.String), - homePath: Schema.optionalKey(Schema.String), + binaryPath: Schema.optionalKey(TrimmedString), + homePath: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), - launchArgs: Schema.optionalKey(Schema.String), + launchArgs: Schema.optionalKey(TrimmedString), }); const CursorSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(Schema.String), - apiEndpoint: Schema.optionalKey(Schema.String), + binaryPath: Schema.optionalKey(TrimmedString), + apiEndpoint: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(Schema.String), - serverUrl: Schema.optionalKey(Schema.String), - serverPassword: Schema.optionalKey(Schema.String), + binaryPath: Schema.optionalKey(TrimmedString), + serverUrl: Schema.optionalKey(TrimmedString), + serverPassword: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), + automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), - addProjectBaseDirectory: Schema.optionalKey(Schema.String), + addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), observability: Schema.optionalKey( Schema.Struct({ - otlpTracesUrl: Schema.optionalKey(Schema.String), - otlpMetricsUrl: Schema.optionalKey(Schema.String), + otlpTracesUrl: Schema.optionalKey(TrimmedString), + otlpMetricsUrl: Schema.optionalKey(TrimmedString), }), ), providers: Schema.optionalKey( diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 6427129b3c..1bbf466f60 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -76,14 +76,15 @@ export function applyServerSettingsPatch( patch: ServerSettingsPatch, ): ServerSettings { const selectionPatch = patch.textGenerationModelSelection; - const next = deepMerge(current, patch); - const nextWithReplacements = - patch.providerInstances !== undefined - ? { - ...next, - providerInstances: patch.providerInstances, - } - : next; + const { automaticGitFetchInterval, ...patchForMerge } = patch; + const next = deepMerge(current, patchForMerge); + const nextWithReplacements = { + ...next, + ...(patch.providerInstances !== undefined + ? { providerInstances: patch.providerInstances } + : {}), + ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), + }; if (!selectionPatch) { return nextWithReplacements; }