authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-08 16:41:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-08 16:41:55-07:00
log34ec8a86fb3a76f5d65e6805dcf98d785536b6f8
tree336ee6ab0abb3b1ef30d172c42657457f32f7ee3
parent932df4ed17ff9465053e3a1bbfdd6d2ba23500f0
signature Signed by PGP key B5690EEEBB952194

[codex] add configurable automatic git fetch interval (#2605)

Co-authored-by: Julius Marminge <julius@macmini.local> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>

22 files changed, 517 insertions(+), 106 deletions(-)

apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+1-1
......@@ -1601,7 +1601,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
16011601 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
16021602 }),
16031603 ),
1604 updatedAt: updatedAt ?? new Date(0).toISOString(),
1604 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
16051605 };
16061606
16071607 return yield* decodeShellSnapshot(snapshot).pipe(
apps/server/src/provider/Layers/ProviderRegistry.test.ts+9-5
......@@ -27,6 +27,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
2727import { ChildProcessSpawner } from "effect/unstable/process";
2828import { deepMerge } from "@t3tools/shared/Struct";
2929import { createModelCapabilities } from "@t3tools/shared/model";
30import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
3031
3132import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
3233import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
......@@ -48,6 +49,8 @@ import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.t
4849import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
4950import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
5051const decodeServerSettings = Schema.decodeSync(ServerSettings);
52const encodeServerSettings = Schema.encodeSync(ServerSettings);
53const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS);
5154
5255const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
5356const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});
......@@ -256,7 +259,8 @@ function makeMutableServerSettingsService(
256259 updateSettings: (patch) =>
257260 Effect.gen(function* () {
258261 const current = yield* Ref.get(settingsRef);
259 const next = decodeServerSettings(deepMerge(current, patch));
262 const next = applyServerSettingsPatch(current, patch);
263 encodeServerSettings(next);
260264 yield* Ref.set(settingsRef, next);
261265 yield* PubSub.publish(changes, next);
262266 return next;
......@@ -930,7 +934,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
930934 const missingBinary = `t3code_codex_missing_`;
931935 const serverSettings = yield* makeMutableServerSettingsService(
932936 decodeServerSettings(
933 deepMerge(DEFAULT_SERVER_SETTINGS, {
937 deepMerge(encodedDefaultServerSettings, {
934938 providers: {
935939 // Disable every built-in probe that would otherwise spawn
936940 // on the CI host. `enabled: false` short-circuits each
......@@ -1029,7 +1033,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
10291033 const secondMissing = `t3code_codex_second_`;
10301034 const serverSettings = yield* makeMutableServerSettingsService(
10311035 decodeServerSettings(
1032 deepMerge(DEFAULT_SERVER_SETTINGS, {
1036 deepMerge(encodedDefaultServerSettings, {
10331037 providers: {
10341038 codex: { enabled: true, binaryPath: firstMissing },
10351039 claudeAgent: { enabled: false },
......@@ -1124,7 +1128,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
11241128 Effect.gen(function* () {
11251129 const serverSettings = yield* makeMutableServerSettingsService(
11261130 decodeServerSettings(
1127 deepMerge(DEFAULT_SERVER_SETTINGS, {
1131 deepMerge(encodedDefaultServerSettings, {
11281132 providers: {
11291133 codex: { enabled: false },
11301134 claudeAgent: { enabled: false },
......@@ -1180,7 +1184,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
11801184 Effect.gen(function* () {
11811185 const serverSettings = yield* makeMutableServerSettingsService(
11821186 decodeServerSettings(
1183 deepMerge(DEFAULT_SERVER_SETTINGS, {
1187 deepMerge(encodedDefaultServerSettings, {
11841188 providers: {
11851189 codex: {
11861190 enabled: false,
apps/server/src/server.test.ts+1-1
......@@ -635,7 +635,7 @@ const buildAppUnderTest = (options?: {
635635 snapshotSequence: 0,
636636 projects: [],
637637 threads: [],
638 updatedAt: new Date(0).toISOString(),
638 updatedAt: "1970-01-01T00:00:00.000Z",
639639 }),
640640 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
641641 getProjectShellById: () => Effect.succeed(Option.none()),
apps/server/src/serverSettings.test.ts+3
......@@ -9,6 +9,7 @@ import {
99import { createModelSelection } from "@t3tools/shared/model";
1010import { assert, it } from "@effect/vitest";
1111import * as Effect from "effect/Effect";
12import * as Duration from "effect/Duration";
1213import * as FileSystem from "effect/FileSystem";
1314import * as Layer from "effect/Layer";
1415import * as Schema from "effect/Schema";
......@@ -437,6 +438,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
437438 serverPassword: "secret-password",
438439 },
439440 },
441 automaticGitFetchInterval: Duration.seconds(10),
440442 });
441443
442444 assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex");
......@@ -458,6 +460,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
458460 serverPassword: "secret-password",
459461 },
460462 },
463 automaticGitFetchInterval: 10_000,
461464 });
462465 }).pipe(Effect.provide(makeServerSettingsLayer())),
463466 );
apps/server/src/serverSettings.ts+58-46
......@@ -45,15 +45,33 @@ import * as Semaphore from "effect/Semaphore";
4545import { writeFileStringAtomically } from "./atomicWrite.ts";
4646import { ServerConfig } from "./config.ts";
4747import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
48import { fromLenientJson } from "@t3tools/shared/schemaJson";
48import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
4949import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
5050import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
5151import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts";
52const decodeServerSettings = Schema.decodeEffect(ServerSettings);
52
53const encodeServerSettings = Schema.encodeEffect(ServerSettings);
54const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings));
55const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
5356
5457const textEncoder = new TextEncoder();
5558const textDecoder = new TextDecoder();
5659
60const normalizeServerSettings = (
61 settings: ServerSettings,
62): Effect.Effect<ServerSettings, ServerSettingsError> =>
63 encodeServerSettings(settings).pipe(
64 Effect.flatMap(decodeServerSettings),
65 Effect.mapError(
66 (cause) =>
67 new ServerSettingsError({
68 settingsPath: "<memory>",
69 detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`,
70 cause,
71 }),
72 ),
73 );
74
5775function providerEnvironmentSecretName(input: {
5876 readonly instanceId: string;
5977 readonly name: string;
......@@ -117,9 +135,15 @@ export class ServerSettingsService extends Context.Service<
117135 Layer.effect(
118136 ServerSettingsService,
119137 Effect.gen(function* () {
120 const currentSettingsRef = yield* Ref.make<ServerSettings>(
121 deepMerge(DEFAULT_SERVER_SETTINGS, overrides),
122 );
138 const { automaticGitFetchInterval, ...overridesForMerge } = overrides;
139 const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge);
140 const initialSettings = yield* normalizeServerSettings({
141 ...merged,
142 ...(automaticGitFetchInterval !== undefined
143 ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration }
144 : {}),
145 });
146 const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings);
123147
124148 return {
125149 start: Effect.void,
......@@ -127,18 +151,8 @@ export class ServerSettingsService extends Context.Service<
127151 getSettings: Ref.get(currentSettingsRef),
128152 updateSettings: (patch) =>
129153 Ref.get(currentSettingsRef).pipe(
130 Effect.flatMap((currentSettings) =>
131 decodeServerSettings(applyServerSettingsPatch(currentSettings, patch)).pipe(
132 Effect.mapError(
133 (cause) =>
134 new ServerSettingsError({
135 settingsPath: "<memory>",
136 detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`,
137 cause,
138 }),
139 ),
140 ),
141 ),
154 Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)),
155 Effect.flatMap(normalizeServerSettings),
142156 Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)),
143157 ),
144158 streamChanges: Stream.empty,
......@@ -200,7 +214,10 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting
200214}
201215
202216// Values under these keys are compared as a whole — never stripped field-by-field.
203const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set(["textGenerationModelSelection"]);
217const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
218 "automaticGitFetchInterval",
219 "textGenerationModelSelection",
220]);
204221
205222function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {
206223 if (Array.isArray(current) || Array.isArray(defaults)) {
......@@ -430,25 +447,29 @@ const makeServerSettings = Effect.gen(function* () {
430447 };
431448 });
432449
433 const writeSettingsAtomically = (settings: ServerSettings) => {
434 const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {};
450 const writeSettingsAtomically = Effect.fnUntraced(
451 function* (settings: ServerSettings) {
452 const sparseSettingsJson = yield* encodeServerSettingsJson(
453 stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {},
454 );
435455
436 return writeFileStringAtomically({
437 filePath: settingsPath,
438 contents: `${JSON.stringify(sparseSettings, null, 2)}\n`,
439 }).pipe(
440 Effect.provideService(FileSystem.FileSystem, fs),
441 Effect.provideService(Path.Path, pathService),
442 Effect.mapError(
443 (cause) =>
444 new ServerSettingsError({
445 settingsPath,
446 detail: "failed to write settings file",
447 cause,
448 }),
449 ),
450 );
451 };
456 return yield* writeFileStringAtomically({
457 filePath: settingsPath,
458 contents: `${sparseSettingsJson}\n`,
459 }).pipe(
460 Effect.provideService(FileSystem.FileSystem, fs),
461 Effect.provideService(Path.Path, pathService),
462 );
463 },
464 Effect.mapError(
465 (cause) =>
466 new ServerSettingsError({
467 settingsPath,
468 detail: "failed to write settings file",
469 cause,
470 }),
471 ),
472 );
452473
453474 const revalidateAndEmit = writeSemaphore.withPermits(1)(
454475 Effect.gen(function* () {
......@@ -533,16 +554,7 @@ const makeServerSettings = Effect.gen(function* () {
533554 current,
534555 applyServerSettingsPatch(current, patch),
535556 );
536 const next = yield* decodeServerSettings(nextPersisted).pipe(
537 Effect.mapError(
538 (cause) =>
539 new ServerSettingsError({
540 settingsPath: "<memory>",
541 detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`,
542 cause,
543 }),
544 ),
545 );
557 const next = yield* normalizeServerSettings(nextPersisted);
546558 yield* writeSettingsAtomically(next);
547559 yield* Cache.set(settingsCache, cacheKey, next);
548560 yield* emitChange(next);
apps/server/src/vcs/GitVcsDriverCore.test.ts+59
......@@ -130,6 +130,65 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
130130 }),
131131 );
132132
133 it.effect("disables SSH askpass for background upstream status fetches", () =>
134 Effect.gen(function* () {
135 const cwd = yield* makeTmpDir();
136 const tempDir = yield* makeTmpDir("git-vcs-driver-ssh-env-");
137 const { initialBranch } = yield* initRepoWithCommit(cwd);
138 const fileSystem = yield* FileSystem.FileSystem;
139 const pathService = yield* Path.Path;
140 const sshLogPath = pathService.join(tempDir, "ssh-env.txt");
141 const sshWrapperPath = pathService.join(tempDir, "ssh-wrapper.sh");
142 const previousGitSsh = process.env.GIT_SSH;
143 const previousAskpassRequire = process.env.SSH_ASKPASS_REQUIRE;
144 const previousAskpassLog = process.env.T3_TEST_SSH_ASKPASS_LOG;
145
146 yield* fileSystem.writeFileString(
147 sshWrapperPath,
148 [
149 "#!/bin/sh",
150 'printf "%s\\n" "${SSH_ASKPASS_REQUIRE:-}" > "$T3_TEST_SSH_ASKPASS_LOG"',
151 "exit 1",
152 "",
153 ].join("\n"),
154 );
155 yield* fileSystem.chmod(sshWrapperPath, 0o755);
156 yield* git(cwd, ["remote", "add", "origin", "ssh://example.invalid/repo.git"]);
157 yield* git(cwd, ["update-ref", `refs/remotes/origin/${initialBranch}`, "HEAD"]);
158 yield* git(cwd, ["branch", "--set-upstream-to", `origin/${initialBranch}`]);
159
160 yield* Effect.gen(function* () {
161 process.env.GIT_SSH = sshWrapperPath;
162 process.env.SSH_ASKPASS_REQUIRE = "force";
163 process.env.T3_TEST_SSH_ASKPASS_LOG = sshLogPath;
164
165 yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd);
166
167 assert.equal((yield* fileSystem.readFileString(sshLogPath)).trim(), "never");
168 }).pipe(
169 Effect.ensuring(
170 Effect.sync(() => {
171 if (previousGitSsh === undefined) {
172 delete process.env.GIT_SSH;
173 } else {
174 process.env.GIT_SSH = previousGitSsh;
175 }
176 if (previousAskpassRequire === undefined) {
177 delete process.env.SSH_ASKPASS_REQUIRE;
178 } else {
179 process.env.SSH_ASKPASS_REQUIRE = previousAskpassRequire;
180 }
181 if (previousAskpassLog === undefined) {
182 delete process.env.T3_TEST_SSH_ASKPASS_LOG;
183 } else {
184 process.env.T3_TEST_SSH_ASKPASS_LOG = previousAskpassLog;
185 }
186 }),
187 ),
188 );
189 }),
190 );
191
133192 it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () =>
134193 Effect.gen(function* () {
135194 const cwd = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+6
......@@ -41,6 +41,9 @@ const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
4141const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
4242const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
4343const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
44const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({
45 SSH_ASKPASS_REQUIRE: "never",
46} satisfies NodeJS.ProcessEnv);
4447const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
4548const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
4649const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({
......@@ -72,6 +75,7 @@ interface ExecuteGitOptions {
7275 timeoutMs?: number | undefined;
7376 allowNonZeroExit?: boolean | undefined;
7477 fallbackErrorMessage?: string | undefined;
78 env?: NodeJS.ProcessEnv | undefined;
7579 maxOutputBytes?: number | undefined;
7680 truncateOutputAtMaxBytes?: boolean | undefined;
7781 progress?: GitVcsDriver.ExecuteGitProgress | undefined;
......@@ -738,6 +742,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
738742 cwd,
739743 args,
740744 ...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
745 ...(options.env !== undefined ? { env: options.env } : {}),
741746 allowNonZeroExit: true,
742747 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
743748 ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
......@@ -870,6 +875,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
870875 ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName],
871876 {
872877 allowNonZeroExit: true,
878 env: STATUS_UPSTREAM_REFRESH_ENV,
873879 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
874880 },
875881 ).pipe(Effect.asVoid);
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+26
......@@ -1,6 +1,7 @@
11import { assert, it, describe } from "@effect/vitest";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import * as Deferred from "effect/Deferred";
4import * as Duration from "effect/Duration";
45import * as Effect from "effect/Effect";
56import * as Exit from "effect/Exit";
67import * as FileSystem from "effect/FileSystem";
......@@ -284,6 +285,31 @@ describe("VcsStatusBroadcaster", () => {
284285 }).pipe(Effect.provide(makeTestLayer(state)));
285286 });
286287
288 it.effect("does not start automatic remote refreshes when disabled", () => {
289 const state = {
290 currentLocalStatus: baseLocalStatus,
291 currentRemoteStatus: baseRemoteStatus,
292 localStatusCalls: 0,
293 remoteStatusCalls: 0,
294 localInvalidationCalls: 0,
295 remoteInvalidationCalls: 0,
296 };
297
298 return Effect.gen(function* () {
299 const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
300 const snapshot = yield* Stream.runHead(
301 broadcaster.streamStatus(
302 { cwd: "/repo" },
303 { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) },
304 ),
305 );
306
307 assert.isTrue(Option.isSome(snapshot));
308 assert.equal(state.remoteStatusCalls, 0);
309 assert.equal(state.remoteInvalidationCalls, 0);
310 }).pipe(Effect.provide(makeTestLayer(state)));
311 });
312
287313 it.effect("stops the remote poller after the last stream subscriber disconnects", () => {
288314 const state = {
289315 currentLocalStatus: baseLocalStatus,
apps/server/src/vcs/VcsStatusBroadcaster.ts+32-9
......@@ -22,7 +22,7 @@ import { mergeGitStatusParts } from "@t3tools/shared/git";
2222
2323import * as GitWorkflowService from "../git/GitWorkflowService.ts";
2424
25const VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
25const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
2626
2727interface VcsStatusChange {
2828 readonly cwd: string;
......@@ -44,6 +44,10 @@ interface ActiveRemotePoller {
4444 readonly subscriberCount: number;
4545}
4646
47interface StreamStatusOptions {
48 readonly automaticRemoteRefreshInterval?: Effect.Effect<Duration.Duration, never>;
49}
50
4751export interface VcsStatusBroadcasterShape {
4852 readonly getStatus: (
4953 input: VcsStatusInput,
......@@ -54,6 +58,7 @@ export interface VcsStatusBroadcasterShape {
5458 readonly refreshStatus: (cwd: string) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
5559 readonly streamStatus: (
5660 input: VcsStatusInput,
61 options?: StreamStatusOptions,
5762 ) => Stream.Stream<VcsStatusStreamEvent, GitManagerServiceError>;
5863}
5964
......@@ -232,19 +237,32 @@ export const layer = Layer.effect(
232237 return mergeGitStatusParts(local, remote);
233238 });
234239
235 const makeRemoteRefreshLoop = (cwd: string) => {
236 const logRefreshFailure = (error: Error) =>
240 const makeRemoteRefreshLoop = (
241 cwd: string,
242 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
243 ) => {
244 const logRefreshFailure = (error: GitManagerServiceError) =>
237245 Effect.logWarning("VCS remote status refresh failed", {
238246 cwd,
239247 detail: error.message,
240248 });
249 const refreshRemoteStatusIfEnabled = automaticRemoteRefreshInterval.pipe(
250 Effect.flatMap((interval) =>
251 Duration.isZero(interval) ? Effect.void : refreshRemoteStatus(cwd).pipe(Effect.asVoid),
252 ),
253 );
254 const sleepForConfiguredInterval = automaticRemoteRefreshInterval.pipe(
255 Effect.flatMap((interval) =>
256 Effect.sleep(Duration.isZero(interval) ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL : interval),
257 ),
258 );
241259
242 return refreshRemoteStatus(cwd).pipe(
260 return refreshRemoteStatusIfEnabled.pipe(
243261 Effect.catch(logRefreshFailure),
244262 Effect.andThen(
245263 Effect.forever(
246 Effect.sleep(VCS_STATUS_REFRESH_INTERVAL).pipe(
247 Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))),
264 sleepForConfiguredInterval.pipe(
265 Effect.andThen(refreshRemoteStatusIfEnabled.pipe(Effect.catch(logRefreshFailure))),
248266 ),
249267 ),
250268 ),
......@@ -253,6 +271,7 @@ export const layer = Layer.effect(
253271
254272 const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* (
255273 cwd: string,
274 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
256275 ) {
257276 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
258277 const existing = activePollers.get(cwd);
......@@ -265,7 +284,7 @@ export const layer = Layer.effect(
265284 return Effect.succeed([undefined, nextPollers] as const);
266285 }
267286
268 return makeRemoteRefreshLoop(cwd).pipe(
287 return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval).pipe(
269288 Effect.forkIn(broadcasterScope),
270289 Effect.map((fiber) => {
271290 const nextPollers = new Map(activePollers);
......@@ -307,14 +326,18 @@ export const layer = Layer.effect(
307326 }
308327 });
309328
310 const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input) =>
329 const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input, options) =>
311330 Stream.unwrap(
312331 Effect.gen(function* () {
313332 const cwd = yield* withFileSystem(normalizeCwd(input.cwd));
314333 const subscription = yield* PubSub.subscribe(changesPubSub);
315334 const initialLocal = yield* getOrLoadLocalStatus(cwd);
316335 const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null;
317 yield* retainRemotePoller(cwd);
336 yield* retainRemotePoller(
337 cwd,
338 options?.automaticRemoteRefreshInterval ??
339 Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL),
340 );
318341
319342 const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid);
320343
apps/server/src/ws.ts+12-1
......@@ -9,6 +9,7 @@ import * as Ref from "effect/Ref";
99import * as Schema from "effect/Schema";
1010import * as Stream from "effect/Stream";
1111import {
12 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,
1213 type AuthAccessStreamEvent,
1314 AuthSessionId,
1415 CommandId,
......@@ -180,6 +181,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
180181 const serverEnvironment = yield* ServerEnvironment;
181182 const serverAuth = yield* ServerAuth;
182183 const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery;
184 const automaticGitFetchInterval = serverSettings.getSettings.pipe(
185 Effect.map((settings) => settings.automaticGitFetchInterval),
186 Effect.catch((cause) =>
187 Effect.logWarning("Failed to read automatic Git fetch interval setting", {
188 detail: cause.message,
189 }).pipe(Effect.as(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)),
190 ),
191 );
183192 const sourceControlRepositories = yield* SourceControlRepositoryService;
184193 const bootstrapCredentials = yield* BootstrapCredentialService;
185194 const sessions = yield* SessionCredentialService;
......@@ -981,7 +990,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
981990 [WS_METHODS.subscribeVcsStatus]: (input) =>
982991 observeRpcStream(
983992 WS_METHODS.subscribeVcsStatus,
984 vcsStatusBroadcaster.streamStatus(input),
993 vcsStatusBroadcaster.streamStatus(input, {
994 automaticRemoteRefreshInterval: automaticGitFetchInterval,
995 }),
985996 {
986997 "rpc.aggregate": "vcs",
987998 },
apps/web/src/components/ChatView.browser.tsx+5-2
......@@ -18,11 +18,13 @@ import {
1818 WS_METHODS,
1919 OrchestrationSessionStatus,
2020 DEFAULT_SERVER_SETTINGS,
21 ServerConfig as ServerConfigSchema,
2122} from "@t3tools/contracts";
2223import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
2324import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model";
2425import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
2526import * as Option from "effect/Option";
27import * as Schema from "effect/Schema";
2628import { HttpResponse, http, ws } from "msw";
2729import { setupWorker } from "msw/browser";
2830import { page } from "vitest/browser";
......@@ -106,6 +108,7 @@ const rpcHarness = new BrowserWsRpcHarness();
106108const wsRequests = rpcHarness.requests;
107109let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null;
108110const wsLink = ws.link(/ws(s)?:\/\/.*/);
111const encodeServerConfig = Schema.encodeSync(ServerConfigSchema);
109112
110113interface ViewportSpec {
111114 name: string;
......@@ -955,7 +958,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
955958 }
956959 const tag = body._tag;
957960 if (tag === WS_METHODS.serverGetConfig) {
958 return fixture.serverConfig;
961 return encodeServerConfig(fixture.serverConfig);
959962 }
960963 if (tag === WS_METHODS.serverDiscoverSourceControl) {
961964 return {
......@@ -1662,7 +1665,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
16621665 {
16631666 version: 1,
16641667 type: "snapshot",
1665 config: fixture.serverConfig,
1668 config: encodeServerConfig(fixture.serverConfig),
16661669 },
16671670 ];
16681671 }
apps/web/src/components/KeybindingsToast.browser.tsx+8-3
......@@ -11,12 +11,15 @@ import {
1111 ProviderInstanceId,
1212 type ServerConfig,
1313 type ServerLifecycleWelcomePayload,
14 ServerConfig as ServerConfigSchema,
15 ServerSettings,
1416 type ThreadId,
1517 WS_METHODS,
1618} from "@t3tools/contracts";
1719import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
1820import { ws, http, HttpResponse } from "msw";
1921import { setupWorker } from "msw/browser";
22import * as Schema from "effect/Schema";
2023import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2124import { render } from "vitest-browser-react";
2225
......@@ -50,6 +53,8 @@ interface TestFixture {
5053
5154let fixture: TestFixture;
5255const rpcHarness = new BrowserWsRpcHarness();
56const encodeServerConfig = Schema.encodeSync(ServerConfigSchema);
57const encodeServerSettings = Schema.encodeSync(ServerSettings);
5358
5459const wsLink = ws.link(/ws(s)?:\/\/.*/);
5560
......@@ -254,7 +259,7 @@ function buildFixture(): TestFixture {
254259
255260function resolveWsRpc(tag: string): unknown {
256261 if (tag === WS_METHODS.serverGetConfig) {
257 return fixture.serverConfig;
262 return encodeServerConfig(fixture.serverConfig);
258263 }
259264 if (tag === WS_METHODS.vcsListRefs) {
260265 return {
......@@ -394,7 +399,7 @@ async function waitForServerConfigStreamReady(): Promise<void> {
394399 rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, {
395400 version: 1,
396401 type: "settingsUpdated",
397 payload: { settings: fixture.serverConfig.settings },
402 payload: { settings: encodeServerSettings(fixture.serverConfig.settings) },
398403 });
399404
400405 try {
......@@ -485,7 +490,7 @@ describe("Keybindings update toast", () => {
485490 {
486491 version: 1,
487492 type: "snapshot",
488 config: fixture.serverConfig,
493 config: encodeServerConfig(fixture.serverConfig),
489494 },
490495 ];
491496 }
apps/web/src/components/settings/SettingsPanels.browser.tsx+40-3
......@@ -1256,10 +1256,47 @@ describe("SourceControlSettingsPanel discovery states", () => {
12561256 </AppAtomRegistryProvider>,
12571257 );
12581258
1259 await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument();
1259 await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled();
12601260 await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument();
12611261 });
12621262
1263 it("shows Git fetch interval settings inside the Git details dropdown", async () => {
1264 setSourceControlDiscoveryStub(async () => ({
1265 versionControlSystems: [
1266 {
1267 kind: "git",
1268 label: "Git",
1269 executable: "git",
1270 implemented: true,
1271 status: "available",
1272 version: Option.some("git version 2.50.0"),
1273 installHint: "Install Git.",
1274 detail: Option.none(),
1275 },
1276 ],
1277 sourceControlProviders: [],
1278 }));
1279
1280 mounted = await render(
1281 <AppAtomRegistryProvider>
1282 <SourceControlSettingsPanel />
1283 </AppAtomRegistryProvider>,
1284 );
1285
1286 const toggle = page.getByRole("button", { name: "Toggle Git details" });
1287 await expect.element(toggle).toHaveAttribute("aria-expanded", "false");
1288
1289 await toggle.click();
1290
1291 await expect.element(toggle).toHaveAttribute("aria-expanded", "true");
1292 await expect
1293 .element(page.getByLabelText("Automatic Git fetch interval in seconds"))
1294 .toBeVisible();
1295 await expect
1296 .element(page.getByText("Automatic Git fetches run every 30 seconds"))
1297 .not.toBeInTheDocument();
1298 });
1299
12631300 it("does not rescan on remount while the discovery atom is fresh", async () => {
12641301 let calls = 0;
12651302 setSourceControlDiscoveryStub(async () => {
......@@ -1287,7 +1324,7 @@ describe("SourceControlSettingsPanel discovery states", () => {
12871324 </AppAtomRegistryProvider>,
12881325 );
12891326
1290 await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument();
1327 await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled();
12911328 expect(calls).toBe(1);
12921329
12931330 const teardown = mounted.cleanup ?? mounted.unmount;
......@@ -1301,7 +1338,7 @@ describe("SourceControlSettingsPanel discovery states", () => {
13011338 </AppAtomRegistryProvider>,
13021339 );
13031340
1304 await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument();
1341 await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled();
13051342 expect(calls).toBe(1);
13061343 });
13071344});
apps/web/src/components/settings/SettingsPanels.tsx+7
......@@ -14,6 +14,7 @@ import {
1414import { scopeThreadRef } from "@t3tools/client-runtime";
1515import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
1616import { createModelSelection } from "@t3tools/shared/model";
17import * as Duration from "effect/Duration";
1718import * as Equal from "effect/Equal";
1819import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";
1920import {
......@@ -407,6 +408,10 @@ export function useSettingsRestore(onRestored?: () => void) {
407408 ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming
408409 ? ["Assistant output"]
409410 : []),
411 ...(Duration.toMillis(settings.automaticGitFetchInterval) !==
412 Duration.toMillis(DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval)
413 ? ["Automatic Git fetch interval"]
414 : []),
410415 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
411416 ? ["New thread mode"]
412417 : []),
......@@ -430,6 +435,7 @@ export function useSettingsRestore(onRestored?: () => void) {
430435 settings.defaultThreadEnvMode,
431436 settings.diffIgnoreWhitespace,
432437 settings.diffWordWrap,
438 settings.automaticGitFetchInterval,
433439 settings.enableAssistantStreaming,
434440 settings.sidebarThreadPreviewCount,
435441 settings.timestampFormat,
......@@ -455,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) {
455461 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
456462 autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
457463 enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
464 automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
458465 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
459466 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
460467 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
apps/web/src/components/settings/SourceControlSettings.tsx+124-8
......@@ -1,6 +1,7 @@
1import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
1import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
2import * as Duration from "effect/Duration";
23import * as Option from "effect/Option";
3import { type ReactNode } from "react";
4import { useState, type ReactNode } from "react";
45import type {
56 SourceControlProviderKind,
67 SourceControlDiscoveryResult,
......@@ -9,7 +10,9 @@ import type {
910 VcsDriverKind,
1011 VcsDiscoveryItem,
1112} from "@t3tools/contracts";
13import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
1214
15import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
1316import { cn } from "../../lib/utils";
1417import {
1518 refreshSourceControlDiscovery,
......@@ -17,6 +20,7 @@ import {
1720} from "../../lib/sourceControlDiscoveryState";
1821import { Badge } from "../ui/badge";
1922import { Button } from "../ui/button";
23import { Collapsible, CollapsibleContent } from "../ui/collapsible";
2024import {
2125 Empty,
2226 EmptyContent,
......@@ -26,6 +30,13 @@ import {
2630 EmptyTitle,
2731} from "../ui/empty";
2832import { Skeleton } from "../ui/skeleton";
33import {
34 NumberField,
35 NumberFieldDecrement,
36 NumberFieldGroup,
37 NumberFieldIncrement,
38 NumberFieldInput,
39} from "../ui/number-field";
2940import { Switch } from "../ui/switch";
3041import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
3142import {
......@@ -38,7 +49,7 @@ import {
3849 type Icon,
3950} from "../Icons";
4051import { RedactedSensitiveText } from "./RedactedSensitiveText";
41import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
52import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout";
4253
4354const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
4455 versionControlSystems: [],
......@@ -58,6 +69,18 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = {
5869};
5970
6071const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const;
72const GIT_FETCH_INTERVAL_STEP_SECONDS = 5;
73
74function durationToSeconds(duration: Duration.Duration): number {
75 return Math.round(Duration.toMillis(duration) / 1_000);
76}
77
78function normalizeFetchIntervalSeconds(value: number | null): number {
79 if (value === null || !Number.isFinite(value)) {
80 return 0;
81 }
82 return Math.max(0, Math.round(value));
83}
6184
6285function optionLabel(value: Option.Option<string>): string | null {
6386 return Option.getOrNull(value);
......@@ -146,7 +169,7 @@ function itemSummary({
146169 }
147170
148171 if (item.status !== "available") {
149 return <span>Not available on this server — {item.installHint}</span>;
172 return <span>Not available on this server: {item.installHint}</span>;
150173 }
151174
152175 if (auth) {
......@@ -189,8 +212,10 @@ function itemSummary({
189212
190213function DiscoveryItemRow({
191214 item,
215 children,
192216}: {
193217 readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;
218 readonly children?: ReactNode;
194219}) {
195220 const version = optionLabel(item.version);
196221 const enabled =
......@@ -198,6 +223,8 @@ function DiscoveryItemRow({
198223 const auth = isProviderDiscoveryItem(item) ? item.auth : null;
199224 const authStatus = auth ? authPresentation(auth) : null;
200225 const authAccount = auth ? optionLabel(auth.account) : null;
226 const [isExpanded, setIsExpanded] = useState(false);
227 const hasDetails = children !== undefined;
201228
202229 return (
203230 <div
......@@ -211,9 +238,9 @@ function DiscoveryItemRow({
211238 <div className="min-w-0 flex-1 space-y-1">
212239 <div className="flex min-w-0 flex-wrap items-center gap-2">
213240 <SourceControlItemMark item={item} />
214 <h3 className="truncate text-[13px] font-semibold tracking-[-0.01em] text-foreground">
241 <span className="truncate text-[13px] font-semibold tracking-[-0.01em] text-foreground">
215242 {item.label}
216 </h3>
243 </span>
217244 {version ? <code className="text-xs text-muted-foreground">{version}</code> : null}
218245 {isVcsNotReady(item) ? (
219246 <Badge variant="warning" size="sm">
......@@ -231,12 +258,100 @@ function DiscoveryItemRow({
231258 </p>
232259 </div>
233260 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
261 {hasDetails ? (
262 <Button
263 size="sm"
264 variant="ghost"
265 className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
266 onClick={() => setIsExpanded((open) => !open)}
267 aria-expanded={isExpanded}
268 aria-label={`Toggle ${item.label} details`}
269 >
270 <ChevronDownIcon
271 className={cn("size-3.5 transition-transform", isExpanded && "rotate-180")}
272 />
273 </Button>
274 ) : null}
234275 {!isVcsNotReady(item) ? (
235276 <Switch checked={enabled} disabled aria-label={`${item.label} availability`} />
236277 ) : null}
237278 </div>
238279 </div>
239280 </div>
281
282 {hasDetails ? (
283 <Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
284 <CollapsibleContent>
285 <div className="border-t border-border/60 px-4 py-3 sm:px-5">{children}</div>
286 </CollapsibleContent>
287 </Collapsible>
288 ) : null}
289 </div>
290 );
291}
292
293function GitFetchIntervalSettings() {
294 const automaticGitFetchInterval = useSettings((settings) => settings.automaticGitFetchInterval);
295 const { updateSettings } = useUpdateSettings();
296 const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval);
297 const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds(
298 DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
299 );
300 const canResetFetchInterval =
301 automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds;
302
303 return (
304 <div className="grid gap-3">
305 <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
306 <div className="min-w-0 space-y-1">
307 <div className="flex min-w-0 items-center gap-1">
308 <span className="text-xs font-medium text-foreground">Fetch interval</span>
309 <span
310 className={cn(
311 "inline-flex size-5 shrink-0 items-center justify-center transition-opacity",
312 canResetFetchInterval ? "opacity-100" : "pointer-events-none opacity-0",
313 )}
314 aria-hidden={!canResetFetchInterval}
315 >
316 {canResetFetchInterval ? (
317 <SettingResetButton
318 label="fetch interval"
319 onClick={() =>
320 updateSettings({
321 automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
322 })
323 }
324 />
325 ) : null}
326 </span>
327 </div>
328 <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground">
329 Refresh remote branch status in the background. Set this to 0 seconds if Git credentials
330 or security keys should only be prompted by explicit Git actions.
331 </p>
332 </div>
333 <div className="flex shrink-0 items-center gap-2">
334 <NumberField
335 value={automaticGitFetchIntervalSeconds}
336 min={0}
337 step={GIT_FETCH_INTERVAL_STEP_SECONDS}
338 size="sm"
339 className="w-32"
340 onValueChange={(value) =>
341 updateSettings({
342 automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)),
343 })
344 }
345 >
346 <NumberFieldGroup>
347 <NumberFieldDecrement aria-label="Decrease fetch interval" />
348 <NumberFieldInput aria-label="Automatic Git fetch interval in seconds" />
349 <NumberFieldIncrement aria-label="Increase fetch interval" />
350 </NumberFieldGroup>
351 </NumberField>
352 <span className="text-xs text-muted-foreground">seconds</span>
353 </div>
354 </div>
240355 </div>
241356 );
242357}
......@@ -324,7 +439,6 @@ function EmptySourceControlDiscovery({
324439
325440export function SourceControlSettingsPanel() {
326441 const discovery = useSourceControlDiscovery();
327
328442 const result = discovery.data ?? EMPTY_DISCOVERY_RESULT;
329443 const hasDiscoveryItems =
330444 result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0;
......@@ -364,7 +478,9 @@ export function SourceControlSettingsPanel() {
364478 {result.versionControlSystems.length > 0 ? (
365479 <SettingsSection title="Version Control" headerAction={scanButton}>
366480 {result.versionControlSystems.map((item) => (
367 <DiscoveryItemRow key={`vcs:${item.kind}`} item={item} />
481 <DiscoveryItemRow key={`vcs:${item.kind}`} item={item}>
482 {item.kind === "git" ? <GitFetchIntervalSettings /> : undefined}
483 </DiscoveryItemRow>
368484 ))}
369485 </SettingsSection>
370486 ) : null}
apps/web/src/lib/archivedThreadsState.ts+3-2
......@@ -1,9 +1,10 @@
11import { useAtomValue } from "@effect/atom-react";
22import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
3import { Cause, Effect, Option } from "effect";
43import { AsyncResult, Atom } from "effect/unstable/reactivity";
54import { useCallback, useMemo } from "react";
6
5import * as Cause from "effect/Cause";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
78import { readEnvironmentApi } from "../environmentApi";
89import { appAtomRegistry } from "../rpc/atomRegistry";
910
apps/web/src/rpc/wsTransport.test.ts+5-2
......@@ -1,4 +1,5 @@
1import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts";
1import { DEFAULT_SERVER_SETTINGS, ServerSettings, WS_METHODS } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
23import * as Stream from "effect/Stream";
34import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
45
......@@ -18,6 +19,8 @@ import {
1819} from "../rpc/wsConnectionState";
1920import { WsTransport } from "./wsTransport";
2021
22const encodeServerSettings = Schema.encodeSync(ServerSettings);
23
2124type WsEventType = "open" | "message" | "close" | "error";
2225type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
2326type WsListener = (event?: WsEvent) => void;
......@@ -1222,7 +1225,7 @@ describe("WsTransport", () => {
12221225 requestId: requestMessage.id,
12231226 exit: {
12241227 _tag: "Success",
1225 value: DEFAULT_SERVER_SETTINGS,
1228 value: encodeServerSettings(DEFAULT_SERVER_SETTINGS),
12261229 },
12271230 }),
12281231 );
packages/contracts/src/baseSchemas.ts+11-1
......@@ -1,6 +1,16 @@
1import * as Effect from "effect/Effect";
12import * as Schema from "effect/Schema";
3import * as SchemaTransformation from "effect/SchemaTransformation";
24
3export const TrimmedString = Schema.Trim;
5export const TrimmedString = Schema.String.pipe(
6 Schema.decodeTo(
7 Schema.String,
8 SchemaTransformation.transformOrFail({
9 decode: (value) => Effect.succeed(value.trim()),
10 encode: (value) => Effect.succeed(value.trim()),
11 }),
12 ),
13);
414export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty());
515
616export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
packages/contracts/src/providerInstance.test.ts+16
......@@ -109,6 +109,22 @@ describe("ProviderInstanceConfig", () => {
109109 expect(decoded.config).toEqual(opaqueConfig);
110110 });
111111
112 it("trims provider instance envelope fields", () => {
113 const decoded = decodeProviderInstanceConfig({
114 driver: " codex ",
115 displayName: " Codex Personal ",
116 accentColor: " #dc2626 ",
117 environment: [{ name: " OPENROUTER_API_KEY ", value: " sk-or-test " }],
118 });
119
120 expect(decoded).toMatchObject({
121 driver: "codex",
122 displayName: "Codex Personal",
123 accentColor: "#dc2626",
124 environment: [{ name: "OPENROUTER_API_KEY", value: " sk-or-test " }],
125 });
126 });
127
112128 it("decodes generic environment variables on the instance envelope", () => {
113129 const decoded = decodeProviderInstanceConfig({
114130 driver: "claudeAgent",
packages/contracts/src/settings.test.ts+59
......@@ -6,6 +6,7 @@ import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./
66
77const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
88const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
9const encodeServerSettings = Schema.encodeSync(ServerSettings);
910
1011describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
1112 it("defaults to an empty record so legacy configs without the key still decode", () => {
......@@ -92,3 +93,61 @@ describe("ServerSettingsPatch.providerInstances", () => {
9293 expect(patch.providerInstances?.[ollamaId]?.driver).toBe("ollama");
9394 });
9495});
96
97describe("ServerSettingsPatch string normalization", () => {
98 it("trims string settings while decoding patches", () => {
99 const patch = decodeServerSettingsPatch({
100 addProjectBaseDirectory: " ~/Development ",
101 textGenerationModelSelection: { model: " gpt-5.4-mini " },
102 observability: {
103 otlpTracesUrl: " http://localhost:4318/v1/traces ",
104 },
105 providers: {
106 codex: {
107 binaryPath: " /opt/homebrew/bin/codex ",
108 homePath: " ~/.codex ",
109 },
110 },
111 providerInstances: {
112 codex_personal: {
113 driver: " codex ",
114 displayName: " Codex Personal ",
115 config: { homePath: " ~/.codex-personal " },
116 },
117 },
118 });
119
120 expect(patch.addProjectBaseDirectory).toBe("~/Development");
121 expect(patch.textGenerationModelSelection?.model).toBe("gpt-5.4-mini");
122 expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
123 expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex");
124 expect(patch.providers?.codex?.homePath).toBe("~/.codex");
125 expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe(
126 "codex",
127 );
128 expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.displayName).toBe(
129 "Codex Personal",
130 );
131 expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.config).toEqual({
132 homePath: " ~/.codex-personal ",
133 });
134 });
135
136 it("trims encoded server settings values before validation", () => {
137 const defaultSettings = decodeServerSettings({});
138 const encoded = encodeServerSettings({
139 ...defaultSettings,
140 addProjectBaseDirectory: " ~/Development ",
141 providers: {
142 ...defaultSettings.providers,
143 codex: {
144 ...defaultSettings.providers.codex,
145 binaryPath: " /opt/homebrew/bin/codex ",
146 },
147 },
148 });
149
150 expect(encoded.addProjectBaseDirectory).toBe("~/Development");
151 expect(encoded.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex");
152 });
153});
packages/contracts/src/settings.ts+23-14
......@@ -1,4 +1,5 @@
11import * as Effect from "effect/Effect";
2import * as Duration from "effect/Duration";
23import * as Schema from "effect/Schema";
34import * as SchemaTransformation from "effect/SchemaTransformation";
45import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
......@@ -336,8 +337,15 @@ export const ObservabilitySettings = Schema.Struct({
336337});
337338export type ObservabilitySettings = typeof ObservabilitySettings.Type;
338339
340export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30);
341
339342export const ServerSettings = Schema.Struct({
340343 enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
344 automaticGitFetchInterval: Schema.DurationFromMillis.pipe(
345 Schema.withDecodingDefault(
346 Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)),
347 ),
348 ),
341349 defaultThreadEnvMode: ThreadEnvMode.pipe(
342350 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),
343351 ),
......@@ -408,45 +416,46 @@ const ModelSelectionPatch = Schema.Struct({
408416
409417const CodexSettingsPatch = Schema.Struct({
410418 enabled: Schema.optionalKey(Schema.Boolean),
411 binaryPath: Schema.optionalKey(Schema.String),
412 homePath: Schema.optionalKey(Schema.String),
413 shadowHomePath: Schema.optionalKey(Schema.String),
419 binaryPath: Schema.optionalKey(TrimmedString),
420 homePath: Schema.optionalKey(TrimmedString),
421 shadowHomePath: Schema.optionalKey(TrimmedString),
414422 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
415423});
416424
417425const ClaudeSettingsPatch = Schema.Struct({
418426 enabled: Schema.optionalKey(Schema.Boolean),
419 binaryPath: Schema.optionalKey(Schema.String),
420 homePath: Schema.optionalKey(Schema.String),
427 binaryPath: Schema.optionalKey(TrimmedString),
428 homePath: Schema.optionalKey(TrimmedString),
421429 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
422 launchArgs: Schema.optionalKey(Schema.String),
430 launchArgs: Schema.optionalKey(TrimmedString),
423431});
424432
425433const CursorSettingsPatch = Schema.Struct({
426434 enabled: Schema.optionalKey(Schema.Boolean),
427 binaryPath: Schema.optionalKey(Schema.String),
428 apiEndpoint: Schema.optionalKey(Schema.String),
435 binaryPath: Schema.optionalKey(TrimmedString),
436 apiEndpoint: Schema.optionalKey(TrimmedString),
429437 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
430438});
431439
432440const OpenCodeSettingsPatch = Schema.Struct({
433441 enabled: Schema.optionalKey(Schema.Boolean),
434 binaryPath: Schema.optionalKey(Schema.String),
435 serverUrl: Schema.optionalKey(Schema.String),
436 serverPassword: Schema.optionalKey(Schema.String),
442 binaryPath: Schema.optionalKey(TrimmedString),
443 serverUrl: Schema.optionalKey(TrimmedString),
444 serverPassword: Schema.optionalKey(TrimmedString),
437445 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
438446});
439447
440448export const ServerSettingsPatch = Schema.Struct({
441449 // Server settings
442450 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
451 automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis),
443452 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
444 addProjectBaseDirectory: Schema.optionalKey(Schema.String),
453 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
445454 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
446455 observability: Schema.optionalKey(
447456 Schema.Struct({
448 otlpTracesUrl: Schema.optionalKey(Schema.String),
449 otlpMetricsUrl: Schema.optionalKey(Schema.String),
457 otlpTracesUrl: Schema.optionalKey(TrimmedString),
458 otlpMetricsUrl: Schema.optionalKey(TrimmedString),
450459 }),
451460 ),
452461 providers: Schema.optionalKey(
packages/shared/src/serverSettings.ts+9-8
......@@ -76,14 +76,15 @@ export function applyServerSettingsPatch(
7676 patch: ServerSettingsPatch,
7777): ServerSettings {
7878 const selectionPatch = patch.textGenerationModelSelection;
79 const next = deepMerge(current, patch);
80 const nextWithReplacements =
81 patch.providerInstances !== undefined
82 ? {
83 ...next,
84 providerInstances: patch.providerInstances,
85 }
86 : next;
79 const { automaticGitFetchInterval, ...patchForMerge } = patch;
80 const next = deepMerge(current, patchForMerge);
81 const nextWithReplacements = {
82 ...next,
83 ...(patch.providerInstances !== undefined
84 ? { providerInstances: patch.providerInstances }
85 : {}),
86 ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}),
87 };
8788 if (!selectionPatch) {
8889 return nextWithReplacements;
8990 }