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* () {...@@ -1601,7 +1601,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1601 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,1601 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
1602 }),1602 }),
1603 ),1603 ),
1604 updatedAt: updatedAt ?? new Date(0).toISOString(),1604 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
1605 };1605 };
16061606
1607 return yield* decodeShellSnapshot(snapshot).pipe(1607 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";...@@ -27,6 +27,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
27import { ChildProcessSpawner } from "effect/unstable/process";27import { ChildProcessSpawner } from "effect/unstable/process";
28import { deepMerge } from "@t3tools/shared/Struct";28import { deepMerge } from "@t3tools/shared/Struct";
29import { createModelCapabilities } from "@t3tools/shared/model";29import { createModelCapabilities } from "@t3tools/shared/model";
30import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
3031
31import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";32import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
32import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";33import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
...@@ -48,6 +49,8 @@ import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.t...@@ -48,6 +49,8 @@ import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.t
48import { ProviderRegistry } from "../Services/ProviderRegistry.ts";49import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
49import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";50import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
50const decodeServerSettings = Schema.decodeSync(ServerSettings);51const decodeServerSettings = Schema.decodeSync(ServerSettings);
52const encodeServerSettings = Schema.encodeSync(ServerSettings);
53const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS);
5154
52const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});55const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
53const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});56const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});
...@@ -256,7 +259,8 @@ function makeMutableServerSettingsService(...@@ -256,7 +259,8 @@ function makeMutableServerSettingsService(
256 updateSettings: (patch) =>259 updateSettings: (patch) =>
257 Effect.gen(function* () {260 Effect.gen(function* () {
258 const current = yield* Ref.get(settingsRef);261 const current = yield* Ref.get(settingsRef);
259 const next = decodeServerSettings(deepMerge(current, patch));262 const next = applyServerSettingsPatch(current, patch);
263 encodeServerSettings(next);
260 yield* Ref.set(settingsRef, next);264 yield* Ref.set(settingsRef, next);
261 yield* PubSub.publish(changes, next);265 yield* PubSub.publish(changes, next);
262 return next;266 return next;
...@@ -930,7 +934,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T...@@ -930,7 +934,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
930 const missingBinary = `t3code_codex_missing_`;934 const missingBinary = `t3code_codex_missing_`;
931 const serverSettings = yield* makeMutableServerSettingsService(935 const serverSettings = yield* makeMutableServerSettingsService(
932 decodeServerSettings(936 decodeServerSettings(
933 deepMerge(DEFAULT_SERVER_SETTINGS, {937 deepMerge(encodedDefaultServerSettings, {
934 providers: {938 providers: {
935 // Disable every built-in probe that would otherwise spawn939 // Disable every built-in probe that would otherwise spawn
936 // on the CI host. `enabled: false` short-circuits each940 // on the CI host. `enabled: false` short-circuits each
...@@ -1029,7 +1033,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T...@@ -1029,7 +1033,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
1029 const secondMissing = `t3code_codex_second_`;1033 const secondMissing = `t3code_codex_second_`;
1030 const serverSettings = yield* makeMutableServerSettingsService(1034 const serverSettings = yield* makeMutableServerSettingsService(
1031 decodeServerSettings(1035 decodeServerSettings(
1032 deepMerge(DEFAULT_SERVER_SETTINGS, {1036 deepMerge(encodedDefaultServerSettings, {
1033 providers: {1037 providers: {
1034 codex: { enabled: true, binaryPath: firstMissing },1038 codex: { enabled: true, binaryPath: firstMissing },
1035 claudeAgent: { enabled: false },1039 claudeAgent: { enabled: false },
...@@ -1124,7 +1128,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T...@@ -1124,7 +1128,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
1124 Effect.gen(function* () {1128 Effect.gen(function* () {
1125 const serverSettings = yield* makeMutableServerSettingsService(1129 const serverSettings = yield* makeMutableServerSettingsService(
1126 decodeServerSettings(1130 decodeServerSettings(
1127 deepMerge(DEFAULT_SERVER_SETTINGS, {1131 deepMerge(encodedDefaultServerSettings, {
1128 providers: {1132 providers: {
1129 codex: { enabled: false },1133 codex: { enabled: false },
1130 claudeAgent: { enabled: false },1134 claudeAgent: { enabled: false },
...@@ -1180,7 +1184,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T...@@ -1180,7 +1184,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
1180 Effect.gen(function* () {1184 Effect.gen(function* () {
1181 const serverSettings = yield* makeMutableServerSettingsService(1185 const serverSettings = yield* makeMutableServerSettingsService(
1182 decodeServerSettings(1186 decodeServerSettings(
1183 deepMerge(DEFAULT_SERVER_SETTINGS, {1187 deepMerge(encodedDefaultServerSettings, {
1184 providers: {1188 providers: {
1185 codex: {1189 codex: {
1186 enabled: false,1190 enabled: false,
apps/server/src/server.test.ts+1-1
...@@ -635,7 +635,7 @@ const buildAppUnderTest = (options?: {...@@ -635,7 +635,7 @@ const buildAppUnderTest = (options?: {
635 snapshotSequence: 0,635 snapshotSequence: 0,
636 projects: [],636 projects: [],
637 threads: [],637 threads: [],
638 updatedAt: new Date(0).toISOString(),638 updatedAt: "1970-01-01T00:00:00.000Z",
639 }),639 }),
640 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),640 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
641 getProjectShellById: () => Effect.succeed(Option.none()),641 getProjectShellById: () => Effect.succeed(Option.none()),
apps/server/src/serverSettings.test.ts+3
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9import { createModelSelection } from "@t3tools/shared/model";9import { createModelSelection } from "@t3tools/shared/model";
10import { assert, it } from "@effect/vitest";10import { assert, it } from "@effect/vitest";
11import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
12import * as Duration from "effect/Duration";
12import * as FileSystem from "effect/FileSystem";13import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";14import * as Layer from "effect/Layer";
14import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
...@@ -437,6 +438,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -437,6 +438,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
437 serverPassword: "secret-password",438 serverPassword: "secret-password",
438 },439 },
439 },440 },
441 automaticGitFetchInterval: Duration.seconds(10),
440 });442 });
441443
442 assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex");444 assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex");
...@@ -458,6 +460,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -458,6 +460,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
458 serverPassword: "secret-password",460 serverPassword: "secret-password",
459 },461 },
460 },462 },
463 automaticGitFetchInterval: 10_000,
461 });464 });
462 }).pipe(Effect.provide(makeServerSettingsLayer())),465 }).pipe(Effect.provide(makeServerSettingsLayer())),
463 );466 );
apps/server/src/serverSettings.ts+58-46
...@@ -45,15 +45,33 @@ import * as Semaphore from "effect/Semaphore";...@@ -45,15 +45,33 @@ import * as Semaphore from "effect/Semaphore";
45import { writeFileStringAtomically } from "./atomicWrite.ts";45import { writeFileStringAtomically } from "./atomicWrite.ts";
46import { ServerConfig } from "./config.ts";46import { ServerConfig } from "./config.ts";
47import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";47import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
48import { fromLenientJson } from "@t3tools/shared/schemaJson";48import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
49import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";49import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
50import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";50import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
51import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts";51import { 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
54const textEncoder = new TextEncoder();57const textEncoder = new TextEncoder();
55const textDecoder = new TextDecoder();58const 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
57function providerEnvironmentSecretName(input: {75function providerEnvironmentSecretName(input: {
58 readonly instanceId: string;76 readonly instanceId: string;
59 readonly name: string;77 readonly name: string;
...@@ -117,9 +135,15 @@ export class ServerSettingsService extends Context.Service<...@@ -117,9 +135,15 @@ export class ServerSettingsService extends Context.Service<
117 Layer.effect(135 Layer.effect(
118 ServerSettingsService,136 ServerSettingsService,
119 Effect.gen(function* () {137 Effect.gen(function* () {
120 const currentSettingsRef = yield* Ref.make<ServerSettings>(138 const { automaticGitFetchInterval, ...overridesForMerge } = overrides;
121 deepMerge(DEFAULT_SERVER_SETTINGS, overrides),139 const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge);
122 );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
124 return {148 return {
125 start: Effect.void,149 start: Effect.void,
...@@ -127,18 +151,8 @@ export class ServerSettingsService extends Context.Service<...@@ -127,18 +151,8 @@ export class ServerSettingsService extends Context.Service<
127 getSettings: Ref.get(currentSettingsRef),151 getSettings: Ref.get(currentSettingsRef),
128 updateSettings: (patch) =>152 updateSettings: (patch) =>
129 Ref.get(currentSettingsRef).pipe(153 Ref.get(currentSettingsRef).pipe(
130 Effect.flatMap((currentSettings) =>154 Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)),
131 decodeServerSettings(applyServerSettingsPatch(currentSettings, patch)).pipe(155 Effect.flatMap(normalizeServerSettings),
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 ),
142 Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)),156 Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)),
143 ),157 ),
144 streamChanges: Stream.empty,158 streamChanges: Stream.empty,
...@@ -200,7 +214,10 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting...@@ -200,7 +214,10 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting
200}214}
201215
202// Values under these keys are compared as a whole — never stripped field-by-field.216// 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
205function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {222function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {
206 if (Array.isArray(current) || Array.isArray(defaults)) {223 if (Array.isArray(current) || Array.isArray(defaults)) {
...@@ -430,25 +447,29 @@ const makeServerSettings = Effect.gen(function* () {...@@ -430,25 +447,29 @@ const makeServerSettings = Effect.gen(function* () {
430 };447 };
431 });448 });
432449
433 const writeSettingsAtomically = (settings: ServerSettings) => {450 const writeSettingsAtomically = Effect.fnUntraced(
434 const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {};451 function* (settings: ServerSettings) {
452 const sparseSettingsJson = yield* encodeServerSettingsJson(
453 stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {},
454 );
435455
436 return writeFileStringAtomically({456 return yield* writeFileStringAtomically({
437 filePath: settingsPath,457 filePath: settingsPath,
438 contents: `${JSON.stringify(sparseSettings, null, 2)}\n`,458 contents: `${sparseSettingsJson}\n`,
439 }).pipe(459 }).pipe(
440 Effect.provideService(FileSystem.FileSystem, fs),460 Effect.provideService(FileSystem.FileSystem, fs),
441 Effect.provideService(Path.Path, pathService),461 Effect.provideService(Path.Path, pathService),
442 Effect.mapError(462 );
443 (cause) =>463 },
444 new ServerSettingsError({464 Effect.mapError(
445 settingsPath,465 (cause) =>
446 detail: "failed to write settings file",466 new ServerSettingsError({
447 cause,467 settingsPath,
448 }),468 detail: "failed to write settings file",
449 ),469 cause,
450 );470 }),
451 };471 ),
472 );
452473
453 const revalidateAndEmit = writeSemaphore.withPermits(1)(474 const revalidateAndEmit = writeSemaphore.withPermits(1)(
454 Effect.gen(function* () {475 Effect.gen(function* () {
...@@ -533,16 +554,7 @@ const makeServerSettings = Effect.gen(function* () {...@@ -533,16 +554,7 @@ const makeServerSettings = Effect.gen(function* () {
533 current,554 current,
534 applyServerSettingsPatch(current, patch),555 applyServerSettingsPatch(current, patch),
535 );556 );
536 const next = yield* decodeServerSettings(nextPersisted).pipe(557 const next = yield* normalizeServerSettings(nextPersisted);
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 );
546 yield* writeSettingsAtomically(next);558 yield* writeSettingsAtomically(next);
547 yield* Cache.set(settingsCache, cacheKey, next);559 yield* Cache.set(settingsCache, cacheKey, next);
548 yield* emitChange(next);560 yield* emitChange(next);
apps/server/src/vcs/GitVcsDriverCore.test.ts+59
...@@ -130,6 +130,65 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -130,6 +130,65 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
130 }),130 }),
131 );131 );
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
133 it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () =>192 it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () =>
134 Effect.gen(function* () {193 Effect.gen(function* () {
135 const cwd = yield* makeTmpDir();194 const cwd = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+6
...@@ -41,6 +41,9 @@ const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);...@@ -41,6 +41,9 @@ const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
41const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);41const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
42const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);42const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
43const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;43const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
44const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({
45 SSH_ASKPASS_REQUIRE: "never",
46} satisfies NodeJS.ProcessEnv);
44const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;47const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
45const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;48const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
46const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({49const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({
...@@ -72,6 +75,7 @@ interface ExecuteGitOptions {...@@ -72,6 +75,7 @@ interface ExecuteGitOptions {
72 timeoutMs?: number | undefined;75 timeoutMs?: number | undefined;
73 allowNonZeroExit?: boolean | undefined;76 allowNonZeroExit?: boolean | undefined;
74 fallbackErrorMessage?: string | undefined;77 fallbackErrorMessage?: string | undefined;
78 env?: NodeJS.ProcessEnv | undefined;
75 maxOutputBytes?: number | undefined;79 maxOutputBytes?: number | undefined;
76 truncateOutputAtMaxBytes?: boolean | undefined;80 truncateOutputAtMaxBytes?: boolean | undefined;
77 progress?: GitVcsDriver.ExecuteGitProgress | undefined;81 progress?: GitVcsDriver.ExecuteGitProgress | undefined;
...@@ -738,6 +742,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -738,6 +742,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
738 cwd,742 cwd,
739 args,743 args,
740 ...(options.stdin !== undefined ? { stdin: options.stdin } : {}),744 ...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
745 ...(options.env !== undefined ? { env: options.env } : {}),
741 allowNonZeroExit: true,746 allowNonZeroExit: true,
742 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),747 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
743 ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),748 ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
...@@ -870,6 +875,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -870,6 +875,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
870 ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName],875 ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName],
871 {876 {
872 allowNonZeroExit: true,877 allowNonZeroExit: true,
878 env: STATUS_UPSTREAM_REFRESH_ENV,
873 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),879 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
874 },880 },
875 ).pipe(Effect.asVoid);881 ).pipe(Effect.asVoid);
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+26
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { assert, it, describe } from "@effect/vitest";1import { assert, it, describe } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Deferred from "effect/Deferred";3import * as Deferred from "effect/Deferred";
4import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";6import * as Exit from "effect/Exit";
6import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
...@@ -284,6 +285,31 @@ describe("VcsStatusBroadcaster", () => {...@@ -284,6 +285,31 @@ describe("VcsStatusBroadcaster", () => {
284 }).pipe(Effect.provide(makeTestLayer(state)));285 }).pipe(Effect.provide(makeTestLayer(state)));
285 });286 });
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
287 it.effect("stops the remote poller after the last stream subscriber disconnects", () => {313 it.effect("stops the remote poller after the last stream subscriber disconnects", () => {
288 const state = {314 const state = {
289 currentLocalStatus: baseLocalStatus,315 currentLocalStatus: baseLocalStatus,
apps/server/src/vcs/VcsStatusBroadcaster.ts+32-9
...@@ -22,7 +22,7 @@ import { mergeGitStatusParts } from "@t3tools/shared/git";...@@ -22,7 +22,7 @@ import { mergeGitStatusParts } from "@t3tools/shared/git";
2222
23import * as GitWorkflowService from "../git/GitWorkflowService.ts";23import * 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
27interface VcsStatusChange {27interface VcsStatusChange {
28 readonly cwd: string;28 readonly cwd: string;
...@@ -44,6 +44,10 @@ interface ActiveRemotePoller {...@@ -44,6 +44,10 @@ interface ActiveRemotePoller {
44 readonly subscriberCount: number;44 readonly subscriberCount: number;
45}45}
4646
47interface StreamStatusOptions {
48 readonly automaticRemoteRefreshInterval?: Effect.Effect<Duration.Duration, never>;
49}
50
47export interface VcsStatusBroadcasterShape {51export interface VcsStatusBroadcasterShape {
48 readonly getStatus: (52 readonly getStatus: (
49 input: VcsStatusInput,53 input: VcsStatusInput,
...@@ -54,6 +58,7 @@ export interface VcsStatusBroadcasterShape {...@@ -54,6 +58,7 @@ export interface VcsStatusBroadcasterShape {
54 readonly refreshStatus: (cwd: string) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;58 readonly refreshStatus: (cwd: string) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
55 readonly streamStatus: (59 readonly streamStatus: (
56 input: VcsStatusInput,60 input: VcsStatusInput,
61 options?: StreamStatusOptions,
57 ) => Stream.Stream<VcsStatusStreamEvent, GitManagerServiceError>;62 ) => Stream.Stream<VcsStatusStreamEvent, GitManagerServiceError>;
58}63}
5964
...@@ -232,19 +237,32 @@ export const layer = Layer.effect(...@@ -232,19 +237,32 @@ export const layer = Layer.effect(
232 return mergeGitStatusParts(local, remote);237 return mergeGitStatusParts(local, remote);
233 });238 });
234239
235 const makeRemoteRefreshLoop = (cwd: string) => {240 const makeRemoteRefreshLoop = (
236 const logRefreshFailure = (error: Error) =>241 cwd: string,
242 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
243 ) => {
244 const logRefreshFailure = (error: GitManagerServiceError) =>
237 Effect.logWarning("VCS remote status refresh failed", {245 Effect.logWarning("VCS remote status refresh failed", {
238 cwd,246 cwd,
239 detail: error.message,247 detail: error.message,
240 });248 });
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(
243 Effect.catch(logRefreshFailure),261 Effect.catch(logRefreshFailure),
244 Effect.andThen(262 Effect.andThen(
245 Effect.forever(263 Effect.forever(
246 Effect.sleep(VCS_STATUS_REFRESH_INTERVAL).pipe(264 sleepForConfiguredInterval.pipe(
247 Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))),265 Effect.andThen(refreshRemoteStatusIfEnabled.pipe(Effect.catch(logRefreshFailure))),
248 ),266 ),
249 ),267 ),
250 ),268 ),
...@@ -253,6 +271,7 @@ export const layer = Layer.effect(...@@ -253,6 +271,7 @@ export const layer = Layer.effect(
253271
254 const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* (272 const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* (
255 cwd: string,273 cwd: string,
274 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
256 ) {275 ) {
257 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {276 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
258 const existing = activePollers.get(cwd);277 const existing = activePollers.get(cwd);
...@@ -265,7 +284,7 @@ export const layer = Layer.effect(...@@ -265,7 +284,7 @@ export const layer = Layer.effect(
265 return Effect.succeed([undefined, nextPollers] as const);284 return Effect.succeed([undefined, nextPollers] as const);
266 }285 }
267286
268 return makeRemoteRefreshLoop(cwd).pipe(287 return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval).pipe(
269 Effect.forkIn(broadcasterScope),288 Effect.forkIn(broadcasterScope),
270 Effect.map((fiber) => {289 Effect.map((fiber) => {
271 const nextPollers = new Map(activePollers);290 const nextPollers = new Map(activePollers);
...@@ -307,14 +326,18 @@ export const layer = Layer.effect(...@@ -307,14 +326,18 @@ export const layer = Layer.effect(
307 }326 }
308 });327 });
309328
310 const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input) =>329 const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input, options) =>
311 Stream.unwrap(330 Stream.unwrap(
312 Effect.gen(function* () {331 Effect.gen(function* () {
313 const cwd = yield* withFileSystem(normalizeCwd(input.cwd));332 const cwd = yield* withFileSystem(normalizeCwd(input.cwd));
314 const subscription = yield* PubSub.subscribe(changesPubSub);333 const subscription = yield* PubSub.subscribe(changesPubSub);
315 const initialLocal = yield* getOrLoadLocalStatus(cwd);334 const initialLocal = yield* getOrLoadLocalStatus(cwd);
316 const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null;335 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
319 const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid);342 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";...@@ -9,6 +9,7 @@ import * as Ref from "effect/Ref";
9import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
10import * as Stream from "effect/Stream";10import * as Stream from "effect/Stream";
11import {11import {
12 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,
12 type AuthAccessStreamEvent,13 type AuthAccessStreamEvent,
13 AuthSessionId,14 AuthSessionId,
14 CommandId,15 CommandId,
...@@ -180,6 +181,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -180,6 +181,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
180 const serverEnvironment = yield* ServerEnvironment;181 const serverEnvironment = yield* ServerEnvironment;
181 const serverAuth = yield* ServerAuth;182 const serverAuth = yield* ServerAuth;
182 const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery;183 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 );
183 const sourceControlRepositories = yield* SourceControlRepositoryService;192 const sourceControlRepositories = yield* SourceControlRepositoryService;
184 const bootstrapCredentials = yield* BootstrapCredentialService;193 const bootstrapCredentials = yield* BootstrapCredentialService;
185 const sessions = yield* SessionCredentialService;194 const sessions = yield* SessionCredentialService;
...@@ -981,7 +990,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -981,7 +990,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
981 [WS_METHODS.subscribeVcsStatus]: (input) =>990 [WS_METHODS.subscribeVcsStatus]: (input) =>
982 observeRpcStream(991 observeRpcStream(
983 WS_METHODS.subscribeVcsStatus,992 WS_METHODS.subscribeVcsStatus,
984 vcsStatusBroadcaster.streamStatus(input),993 vcsStatusBroadcaster.streamStatus(input, {
994 automaticRemoteRefreshInterval: automaticGitFetchInterval,
995 }),
985 {996 {
986 "rpc.aggregate": "vcs",997 "rpc.aggregate": "vcs",
987 },998 },
apps/web/src/components/ChatView.browser.tsx+5-2
...@@ -18,11 +18,13 @@ import {...@@ -18,11 +18,13 @@ import {
18 WS_METHODS,18 WS_METHODS,
19 OrchestrationSessionStatus,19 OrchestrationSessionStatus,
20 DEFAULT_SERVER_SETTINGS,20 DEFAULT_SERVER_SETTINGS,
21 ServerConfig as ServerConfigSchema,
21} from "@t3tools/contracts";22} from "@t3tools/contracts";
22import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";23import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
23import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model";24import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model";
24import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";25import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
25import * as Option from "effect/Option";26import * as Option from "effect/Option";
27import * as Schema from "effect/Schema";
26import { HttpResponse, http, ws } from "msw";28import { HttpResponse, http, ws } from "msw";
27import { setupWorker } from "msw/browser";29import { setupWorker } from "msw/browser";
28import { page } from "vitest/browser";30import { page } from "vitest/browser";
...@@ -106,6 +108,7 @@ const rpcHarness = new BrowserWsRpcHarness();...@@ -106,6 +108,7 @@ const rpcHarness = new BrowserWsRpcHarness();
106const wsRequests = rpcHarness.requests;108const wsRequests = rpcHarness.requests;
107let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null;109let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null;
108const wsLink = ws.link(/ws(s)?:\/\/.*/);110const wsLink = ws.link(/ws(s)?:\/\/.*/);
111const encodeServerConfig = Schema.encodeSync(ServerConfigSchema);
109112
110interface ViewportSpec {113interface ViewportSpec {
111 name: string;114 name: string;
...@@ -955,7 +958,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {...@@ -955,7 +958,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
955 }958 }
956 const tag = body._tag;959 const tag = body._tag;
957 if (tag === WS_METHODS.serverGetConfig) {960 if (tag === WS_METHODS.serverGetConfig) {
958 return fixture.serverConfig;961 return encodeServerConfig(fixture.serverConfig);
959 }962 }
960 if (tag === WS_METHODS.serverDiscoverSourceControl) {963 if (tag === WS_METHODS.serverDiscoverSourceControl) {
961 return {964 return {
...@@ -1662,7 +1665,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1662,7 +1665,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
1662 {1665 {
1663 version: 1,1666 version: 1,
1664 type: "snapshot",1667 type: "snapshot",
1665 config: fixture.serverConfig,1668 config: encodeServerConfig(fixture.serverConfig),
1666 },1669 },
1667 ];1670 ];
1668 }1671 }
apps/web/src/components/KeybindingsToast.browser.tsx+8-3
...@@ -11,12 +11,15 @@ import {...@@ -11,12 +11,15 @@ import {
11 ProviderInstanceId,11 ProviderInstanceId,
12 type ServerConfig,12 type ServerConfig,
13 type ServerLifecycleWelcomePayload,13 type ServerLifecycleWelcomePayload,
14 ServerConfig as ServerConfigSchema,
15 ServerSettings,
14 type ThreadId,16 type ThreadId,
15 WS_METHODS,17 WS_METHODS,
16} from "@t3tools/contracts";18} from "@t3tools/contracts";
17import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";19import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
18import { ws, http, HttpResponse } from "msw";20import { ws, http, HttpResponse } from "msw";
19import { setupWorker } from "msw/browser";21import { setupWorker } from "msw/browser";
22import * as Schema from "effect/Schema";
20import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";23import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
21import { render } from "vitest-browser-react";24import { render } from "vitest-browser-react";
2225
...@@ -50,6 +53,8 @@ interface TestFixture {...@@ -50,6 +53,8 @@ interface TestFixture {
5053
51let fixture: TestFixture;54let fixture: TestFixture;
52const rpcHarness = new BrowserWsRpcHarness();55const rpcHarness = new BrowserWsRpcHarness();
56const encodeServerConfig = Schema.encodeSync(ServerConfigSchema);
57const encodeServerSettings = Schema.encodeSync(ServerSettings);
5358
54const wsLink = ws.link(/ws(s)?:\/\/.*/);59const wsLink = ws.link(/ws(s)?:\/\/.*/);
5560
...@@ -254,7 +259,7 @@ function buildFixture(): TestFixture {...@@ -254,7 +259,7 @@ function buildFixture(): TestFixture {
254259
255function resolveWsRpc(tag: string): unknown {260function resolveWsRpc(tag: string): unknown {
256 if (tag === WS_METHODS.serverGetConfig) {261 if (tag === WS_METHODS.serverGetConfig) {
257 return fixture.serverConfig;262 return encodeServerConfig(fixture.serverConfig);
258 }263 }
259 if (tag === WS_METHODS.vcsListRefs) {264 if (tag === WS_METHODS.vcsListRefs) {
260 return {265 return {
...@@ -394,7 +399,7 @@ async function waitForServerConfigStreamReady(): Promise<void> {...@@ -394,7 +399,7 @@ async function waitForServerConfigStreamReady(): Promise<void> {
394 rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, {399 rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, {
395 version: 1,400 version: 1,
396 type: "settingsUpdated",401 type: "settingsUpdated",
397 payload: { settings: fixture.serverConfig.settings },402 payload: { settings: encodeServerSettings(fixture.serverConfig.settings) },
398 });403 });
399404
400 try {405 try {
...@@ -485,7 +490,7 @@ describe("Keybindings update toast", () => {...@@ -485,7 +490,7 @@ describe("Keybindings update toast", () => {
485 {490 {
486 version: 1,491 version: 1,
487 type: "snapshot",492 type: "snapshot",
488 config: fixture.serverConfig,493 config: encodeServerConfig(fixture.serverConfig),
489 },494 },
490 ];495 ];
491 }496 }
apps/web/src/components/settings/SettingsPanels.browser.tsx+40-3
...@@ -1256,10 +1256,47 @@ describe("SourceControlSettingsPanel discovery states", () => {...@@ -1256,10 +1256,47 @@ describe("SourceControlSettingsPanel discovery states", () => {
1256 </AppAtomRegistryProvider>,1256 </AppAtomRegistryProvider>,
1257 );1257 );
12581258
1259 await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument();1259 await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled();
1260 await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument();1260 await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument();
1261 });1261 });
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
1263 it("does not rescan on remount while the discovery atom is fresh", async () => {1300 it("does not rescan on remount while the discovery atom is fresh", async () => {
1264 let calls = 0;1301 let calls = 0;
1265 setSourceControlDiscoveryStub(async () => {1302 setSourceControlDiscoveryStub(async () => {
...@@ -1287,7 +1324,7 @@ describe("SourceControlSettingsPanel discovery states", () => {...@@ -1287,7 +1324,7 @@ describe("SourceControlSettingsPanel discovery states", () => {
1287 </AppAtomRegistryProvider>,1324 </AppAtomRegistryProvider>,
1288 );1325 );
12891326
1290 await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument();1327 await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled();
1291 expect(calls).toBe(1);1328 expect(calls).toBe(1);
12921329
1293 const teardown = mounted.cleanup ?? mounted.unmount;1330 const teardown = mounted.cleanup ?? mounted.unmount;
...@@ -1301,7 +1338,7 @@ describe("SourceControlSettingsPanel discovery states", () => {...@@ -1301,7 +1338,7 @@ describe("SourceControlSettingsPanel discovery states", () => {
1301 </AppAtomRegistryProvider>,1338 </AppAtomRegistryProvider>,
1302 );1339 );
13031340
1304 await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument();1341 await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled();
1305 expect(calls).toBe(1);1342 expect(calls).toBe(1);
1306 });1343 });
1307});1344});
apps/web/src/components/settings/SettingsPanels.tsx+7
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14import { scopeThreadRef } from "@t3tools/client-runtime";14import { scopeThreadRef } from "@t3tools/client-runtime";
15import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";15import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
16import { createModelSelection } from "@t3tools/shared/model";16import { createModelSelection } from "@t3tools/shared/model";
17import * as Duration from "effect/Duration";
17import * as Equal from "effect/Equal";18import * as Equal from "effect/Equal";
18import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";19import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";
19import {20import {
...@@ -407,6 +408,10 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -407,6 +408,10 @@ export function useSettingsRestore(onRestored?: () => void) {
407 ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming408 ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming
408 ? ["Assistant output"]409 ? ["Assistant output"]
409 : []),410 : []),
411 ...(Duration.toMillis(settings.automaticGitFetchInterval) !==
412 Duration.toMillis(DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval)
413 ? ["Automatic Git fetch interval"]
414 : []),
410 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode415 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
411 ? ["New thread mode"]416 ? ["New thread mode"]
412 : []),417 : []),
...@@ -430,6 +435,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -430,6 +435,7 @@ export function useSettingsRestore(onRestored?: () => void) {
430 settings.defaultThreadEnvMode,435 settings.defaultThreadEnvMode,
431 settings.diffIgnoreWhitespace,436 settings.diffIgnoreWhitespace,
432 settings.diffWordWrap,437 settings.diffWordWrap,
438 settings.automaticGitFetchInterval,
433 settings.enableAssistantStreaming,439 settings.enableAssistantStreaming,
434 settings.sidebarThreadPreviewCount,440 settings.sidebarThreadPreviewCount,
435 settings.timestampFormat,441 settings.timestampFormat,
...@@ -455,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -455,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) {
455 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,461 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
456 autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,462 autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
457 enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,463 enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
464 automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
458 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,465 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
459 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,466 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
460 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,467 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
apps/web/src/components/settings/SourceControlSettings.tsx+124-8
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react";1import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
2import * as Duration from "effect/Duration";
2import * as Option from "effect/Option";3import * as Option from "effect/Option";
3import { type ReactNode } from "react";4import { useState, type ReactNode } from "react";
4import type {5import type {
5 SourceControlProviderKind,6 SourceControlProviderKind,
6 SourceControlDiscoveryResult,7 SourceControlDiscoveryResult,
...@@ -9,7 +10,9 @@ import type {...@@ -9,7 +10,9 @@ import type {
9 VcsDriverKind,10 VcsDriverKind,
10 VcsDiscoveryItem,11 VcsDiscoveryItem,
11} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
1214
15import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
13import { cn } from "../../lib/utils";16import { cn } from "../../lib/utils";
14import {17import {
15 refreshSourceControlDiscovery,18 refreshSourceControlDiscovery,
...@@ -17,6 +20,7 @@ import {...@@ -17,6 +20,7 @@ import {
17} from "../../lib/sourceControlDiscoveryState";20} from "../../lib/sourceControlDiscoveryState";
18import { Badge } from "../ui/badge";21import { Badge } from "../ui/badge";
19import { Button } from "../ui/button";22import { Button } from "../ui/button";
23import { Collapsible, CollapsibleContent } from "../ui/collapsible";
20import {24import {
21 Empty,25 Empty,
22 EmptyContent,26 EmptyContent,
...@@ -26,6 +30,13 @@ import {...@@ -26,6 +30,13 @@ import {
26 EmptyTitle,30 EmptyTitle,
27} from "../ui/empty";31} from "../ui/empty";
28import { Skeleton } from "../ui/skeleton";32import { Skeleton } from "../ui/skeleton";
33import {
34 NumberField,
35 NumberFieldDecrement,
36 NumberFieldGroup,
37 NumberFieldIncrement,
38 NumberFieldInput,
39} from "../ui/number-field";
29import { Switch } from "../ui/switch";40import { Switch } from "../ui/switch";
30import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";41import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
31import {42import {
...@@ -38,7 +49,7 @@ import {...@@ -38,7 +49,7 @@ import {
38 type Icon,49 type Icon,
39} from "../Icons";50} from "../Icons";
40import { RedactedSensitiveText } from "./RedactedSensitiveText";51import { RedactedSensitiveText } from "./RedactedSensitiveText";
41import { SettingsPageContainer, SettingsSection } from "./settingsLayout";52import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout";
4253
43const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {54const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
44 versionControlSystems: [],55 versionControlSystems: [],
...@@ -58,6 +69,18 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = {...@@ -58,6 +69,18 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = {
58};69};
5970
60const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const;71const 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
62function optionLabel(value: Option.Option<string>): string | null {85function optionLabel(value: Option.Option<string>): string | null {
63 return Option.getOrNull(value);86 return Option.getOrNull(value);
...@@ -146,7 +169,7 @@ function itemSummary({...@@ -146,7 +169,7 @@ function itemSummary({
146 }169 }
147170
148 if (item.status !== "available") {171 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>;
150 }173 }
151174
152 if (auth) {175 if (auth) {
...@@ -189,8 +212,10 @@ function itemSummary({...@@ -189,8 +212,10 @@ function itemSummary({
189212
190function DiscoveryItemRow({213function DiscoveryItemRow({
191 item,214 item,
215 children,
192}: {216}: {
193 readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;217 readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;
218 readonly children?: ReactNode;
194}) {219}) {
195 const version = optionLabel(item.version);220 const version = optionLabel(item.version);
196 const enabled =221 const enabled =
...@@ -198,6 +223,8 @@ function DiscoveryItemRow({...@@ -198,6 +223,8 @@ function DiscoveryItemRow({
198 const auth = isProviderDiscoveryItem(item) ? item.auth : null;223 const auth = isProviderDiscoveryItem(item) ? item.auth : null;
199 const authStatus = auth ? authPresentation(auth) : null;224 const authStatus = auth ? authPresentation(auth) : null;
200 const authAccount = auth ? optionLabel(auth.account) : null;225 const authAccount = auth ? optionLabel(auth.account) : null;
226 const [isExpanded, setIsExpanded] = useState(false);
227 const hasDetails = children !== undefined;
201228
202 return (229 return (
203 <div230 <div
...@@ -211,9 +238,9 @@ function DiscoveryItemRow({...@@ -211,9 +238,9 @@ function DiscoveryItemRow({
211 <div className="min-w-0 flex-1 space-y-1">238 <div className="min-w-0 flex-1 space-y-1">
212 <div className="flex min-w-0 flex-wrap items-center gap-2">239 <div className="flex min-w-0 flex-wrap items-center gap-2">
213 <SourceControlItemMark item={item} />240 <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">
215 {item.label}242 {item.label}
216 </h3>243 </span>
217 {version ? <code className="text-xs text-muted-foreground">{version}</code> : null}244 {version ? <code className="text-xs text-muted-foreground">{version}</code> : null}
218 {isVcsNotReady(item) ? (245 {isVcsNotReady(item) ? (
219 <Badge variant="warning" size="sm">246 <Badge variant="warning" size="sm">
...@@ -231,12 +258,100 @@ function DiscoveryItemRow({...@@ -231,12 +258,100 @@ function DiscoveryItemRow({
231 </p>258 </p>
232 </div>259 </div>
233 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">260 <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}
234 {!isVcsNotReady(item) ? (275 {!isVcsNotReady(item) ? (
235 <Switch checked={enabled} disabled aria-label={`${item.label} availability`} />276 <Switch checked={enabled} disabled aria-label={`${item.label} availability`} />
236 ) : null}277 ) : null}
237 </div>278 </div>
238 </div>279 </div>
239 </div>280 </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>
240 </div>355 </div>
241 );356 );
242}357}
...@@ -324,7 +439,6 @@ function EmptySourceControlDiscovery({...@@ -324,7 +439,6 @@ function EmptySourceControlDiscovery({
324439
325export function SourceControlSettingsPanel() {440export function SourceControlSettingsPanel() {
326 const discovery = useSourceControlDiscovery();441 const discovery = useSourceControlDiscovery();
327
328 const result = discovery.data ?? EMPTY_DISCOVERY_RESULT;442 const result = discovery.data ?? EMPTY_DISCOVERY_RESULT;
329 const hasDiscoveryItems =443 const hasDiscoveryItems =
330 result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0;444 result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0;
...@@ -364,7 +478,9 @@ export function SourceControlSettingsPanel() {...@@ -364,7 +478,9 @@ export function SourceControlSettingsPanel() {
364 {result.versionControlSystems.length > 0 ? (478 {result.versionControlSystems.length > 0 ? (
365 <SettingsSection title="Version Control" headerAction={scanButton}>479 <SettingsSection title="Version Control" headerAction={scanButton}>
366 {result.versionControlSystems.map((item) => (480 {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>
368 ))}484 ))}
369 </SettingsSection>485 </SettingsSection>
370 ) : null}486 ) : null}
apps/web/src/lib/archivedThreadsState.ts+3-2
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";2import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
3import { Cause, Effect, Option } from "effect";
4import { AsyncResult, Atom } from "effect/unstable/reactivity";3import { AsyncResult, Atom } from "effect/unstable/reactivity";
5import { useCallback, useMemo } from "react";4import { useCallback, useMemo } from "react";
65import * as Cause from "effect/Cause";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
7import { readEnvironmentApi } from "../environmentApi";8import { readEnvironmentApi } from "../environmentApi";
8import { appAtomRegistry } from "../rpc/atomRegistry";9import { appAtomRegistry } from "../rpc/atomRegistry";
910
apps/web/src/rpc/wsTransport.test.ts+5-2
...@@ -1,4 +1,5 @@...@@ -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";
2import * as Stream from "effect/Stream";3import * as Stream from "effect/Stream";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
45
...@@ -18,6 +19,8 @@ import {...@@ -18,6 +19,8 @@ import {
18} from "../rpc/wsConnectionState";19} from "../rpc/wsConnectionState";
19import { WsTransport } from "./wsTransport";20import { WsTransport } from "./wsTransport";
2021
22const encodeServerSettings = Schema.encodeSync(ServerSettings);
23
21type WsEventType = "open" | "message" | "close" | "error";24type WsEventType = "open" | "message" | "close" | "error";
22type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };25type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
23type WsListener = (event?: WsEvent) => void;26type WsListener = (event?: WsEvent) => void;
...@@ -1222,7 +1225,7 @@ describe("WsTransport", () => {...@@ -1222,7 +1225,7 @@ describe("WsTransport", () => {
1222 requestId: requestMessage.id,1225 requestId: requestMessage.id,
1223 exit: {1226 exit: {
1224 _tag: "Success",1227 _tag: "Success",
1225 value: DEFAULT_SERVER_SETTINGS,1228 value: encodeServerSettings(DEFAULT_SERVER_SETTINGS),
1226 },1229 },
1227 }),1230 }),
1228 );1231 );
packages/contracts/src/baseSchemas.ts+11-1
...@@ -1,6 +1,16 @@...@@ -1,6 +1,16 @@
1import * as Effect from "effect/Effect";
1import * as Schema from "effect/Schema";2import * 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);
4export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty());14export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty());
515
6export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));16export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
packages/contracts/src/providerInstance.test.ts+16
...@@ -109,6 +109,22 @@ describe("ProviderInstanceConfig", () => {...@@ -109,6 +109,22 @@ describe("ProviderInstanceConfig", () => {
109 expect(decoded.config).toEqual(opaqueConfig);109 expect(decoded.config).toEqual(opaqueConfig);
110 });110 });
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
112 it("decodes generic environment variables on the instance envelope", () => {128 it("decodes generic environment variables on the instance envelope", () => {
113 const decoded = decodeProviderInstanceConfig({129 const decoded = decodeProviderInstanceConfig({
114 driver: "claudeAgent",130 driver: "claudeAgent",
packages/contracts/src/settings.test.ts+59
...@@ -6,6 +6,7 @@ import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./...@@ -6,6 +6,7 @@ import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./
66
7const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);7const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
8const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);8const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
9const encodeServerSettings = Schema.encodeSync(ServerSettings);
910
10describe("ServerSettings.providerInstances (slice-2 invariant)", () => {11describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
11 it("defaults to an empty record so legacy configs without the key still decode", () => {12 it("defaults to an empty record so legacy configs without the key still decode", () => {
...@@ -92,3 +93,61 @@ describe("ServerSettingsPatch.providerInstances", () => {...@@ -92,3 +93,61 @@ describe("ServerSettingsPatch.providerInstances", () => {
92 expect(patch.providerInstances?.[ollamaId]?.driver).toBe("ollama");93 expect(patch.providerInstances?.[ollamaId]?.driver).toBe("ollama");
93 });94 });
94});95});
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 @@...@@ -1,4 +1,5 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2import * as Duration from "effect/Duration";
2import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
3import * as SchemaTransformation from "effect/SchemaTransformation";4import * as SchemaTransformation from "effect/SchemaTransformation";
4import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";5import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
...@@ -336,8 +337,15 @@ export const ObservabilitySettings = Schema.Struct({...@@ -336,8 +337,15 @@ export const ObservabilitySettings = Schema.Struct({
336});337});
337export type ObservabilitySettings = typeof ObservabilitySettings.Type;338export type ObservabilitySettings = typeof ObservabilitySettings.Type;
338339
340export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30);
341
339export const ServerSettings = Schema.Struct({342export const ServerSettings = Schema.Struct({
340 enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),343 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 ),
341 defaultThreadEnvMode: ThreadEnvMode.pipe(349 defaultThreadEnvMode: ThreadEnvMode.pipe(
342 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),350 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),
343 ),351 ),
...@@ -408,45 +416,46 @@ const ModelSelectionPatch = Schema.Struct({...@@ -408,45 +416,46 @@ const ModelSelectionPatch = Schema.Struct({
408416
409const CodexSettingsPatch = Schema.Struct({417const CodexSettingsPatch = Schema.Struct({
410 enabled: Schema.optionalKey(Schema.Boolean),418 enabled: Schema.optionalKey(Schema.Boolean),
411 binaryPath: Schema.optionalKey(Schema.String),419 binaryPath: Schema.optionalKey(TrimmedString),
412 homePath: Schema.optionalKey(Schema.String),420 homePath: Schema.optionalKey(TrimmedString),
413 shadowHomePath: Schema.optionalKey(Schema.String),421 shadowHomePath: Schema.optionalKey(TrimmedString),
414 customModels: Schema.optionalKey(Schema.Array(Schema.String)),422 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
415});423});
416424
417const ClaudeSettingsPatch = Schema.Struct({425const ClaudeSettingsPatch = Schema.Struct({
418 enabled: Schema.optionalKey(Schema.Boolean),426 enabled: Schema.optionalKey(Schema.Boolean),
419 binaryPath: Schema.optionalKey(Schema.String),427 binaryPath: Schema.optionalKey(TrimmedString),
420 homePath: Schema.optionalKey(Schema.String),428 homePath: Schema.optionalKey(TrimmedString),
421 customModels: Schema.optionalKey(Schema.Array(Schema.String)),429 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
422 launchArgs: Schema.optionalKey(Schema.String),430 launchArgs: Schema.optionalKey(TrimmedString),
423});431});
424432
425const CursorSettingsPatch = Schema.Struct({433const CursorSettingsPatch = Schema.Struct({
426 enabled: Schema.optionalKey(Schema.Boolean),434 enabled: Schema.optionalKey(Schema.Boolean),
427 binaryPath: Schema.optionalKey(Schema.String),435 binaryPath: Schema.optionalKey(TrimmedString),
428 apiEndpoint: Schema.optionalKey(Schema.String),436 apiEndpoint: Schema.optionalKey(TrimmedString),
429 customModels: Schema.optionalKey(Schema.Array(Schema.String)),437 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
430});438});
431439
432const OpenCodeSettingsPatch = Schema.Struct({440const OpenCodeSettingsPatch = Schema.Struct({
433 enabled: Schema.optionalKey(Schema.Boolean),441 enabled: Schema.optionalKey(Schema.Boolean),
434 binaryPath: Schema.optionalKey(Schema.String),442 binaryPath: Schema.optionalKey(TrimmedString),
435 serverUrl: Schema.optionalKey(Schema.String),443 serverUrl: Schema.optionalKey(TrimmedString),
436 serverPassword: Schema.optionalKey(Schema.String),444 serverPassword: Schema.optionalKey(TrimmedString),
437 customModels: Schema.optionalKey(Schema.Array(Schema.String)),445 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
438});446});
439447
440export const ServerSettingsPatch = Schema.Struct({448export const ServerSettingsPatch = Schema.Struct({
441 // Server settings449 // Server settings
442 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),450 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
451 automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis),
443 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),452 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
444 addProjectBaseDirectory: Schema.optionalKey(Schema.String),453 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
445 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),454 textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
446 observability: Schema.optionalKey(455 observability: Schema.optionalKey(
447 Schema.Struct({456 Schema.Struct({
448 otlpTracesUrl: Schema.optionalKey(Schema.String),457 otlpTracesUrl: Schema.optionalKey(TrimmedString),
449 otlpMetricsUrl: Schema.optionalKey(Schema.String),458 otlpMetricsUrl: Schema.optionalKey(TrimmedString),
450 }),459 }),
451 ),460 ),
452 providers: Schema.optionalKey(461 providers: Schema.optionalKey(
packages/shared/src/serverSettings.ts+9-8
...@@ -76,14 +76,15 @@ export function applyServerSettingsPatch(...@@ -76,14 +76,15 @@ export function applyServerSettingsPatch(
76 patch: ServerSettingsPatch,76 patch: ServerSettingsPatch,
77): ServerSettings {77): ServerSettings {
78 const selectionPatch = patch.textGenerationModelSelection;78 const selectionPatch = patch.textGenerationModelSelection;
79 const next = deepMerge(current, patch);79 const { automaticGitFetchInterval, ...patchForMerge } = patch;
80 const nextWithReplacements =80 const next = deepMerge(current, patchForMerge);
81 patch.providerInstances !== undefined81 const nextWithReplacements = {
82 ? {82 ...next,
83 ...next,83 ...(patch.providerInstances !== undefined
84 providerInstances: patch.providerInstances,84 ? { providerInstances: patch.providerInstances }
85 }85 : {}),
86 : next;86 ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}),
87 };
87 if (!selectionPatch) {88 if (!selectionPatch) {
88 return nextWithReplacements;89 return nextWithReplacements;
89 }90 }