Skip to content

Commit 099bd6f

Browse files
author
Julius Marminge
committed
add configurable automatic git fetch interval
1 parent 825263b commit 099bd6f

15 files changed

Lines changed: 310 additions & 62 deletions

apps/server/src/provider/Layers/ProviderRegistry.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
2727
import { ChildProcessSpawner } from "effect/unstable/process";
2828
import { deepMerge } from "@t3tools/shared/Struct";
2929
import { createModelCapabilities } from "@t3tools/shared/model";
30+
import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
3031

3132
import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
3233
import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
@@ -48,6 +49,8 @@ import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.t
4849
import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
4950
import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
5051
const decodeServerSettings = Schema.decodeSync(ServerSettings);
52+
const encodeServerSettings = Schema.encodeSync(ServerSettings);
53+
const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS);
5154

5255
const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
5356
const 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/serverSettings.ts

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,33 @@ import { fromLenientJson } from "@t3tools/shared/schemaJson";
4949
import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
5050
import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
5151
import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts";
52-
const decodeServerSettings = Schema.decodeEffect(ServerSettings);
52+
const encodeServerSettingsSync = Schema.encodeSync(ServerSettings);
53+
const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
5354

5455
const textEncoder = new TextEncoder();
5556
const textDecoder = new TextDecoder();
5657

58+
function settingsInputForDecode(settings: ServerSettings): unknown {
59+
return {
60+
...settings,
61+
automaticGitFetchInterval: Duration.toMillis(settings.automaticGitFetchInterval),
62+
};
63+
}
64+
65+
const normalizeServerSettings = (
66+
settings: ServerSettings,
67+
): Effect.Effect<ServerSettings, ServerSettingsError> =>
68+
decodeServerSettings(settingsInputForDecode(settings)).pipe(
69+
Effect.mapError(
70+
(cause) =>
71+
new ServerSettingsError({
72+
settingsPath: "<memory>",
73+
detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`,
74+
cause,
75+
}),
76+
),
77+
);
78+
5779
function providerEnvironmentSecretName(input: {
5880
readonly instanceId: string;
5981
readonly name: string;
@@ -117,28 +139,19 @@ export class ServerSettingsService extends Context.Service<
117139
Layer.effect(
118140
ServerSettingsService,
119141
Effect.gen(function* () {
120-
const currentSettingsRef = yield* Ref.make<ServerSettings>(
142+
const initialSettings = yield* normalizeServerSettings(
121143
deepMerge(DEFAULT_SERVER_SETTINGS, overrides),
122144
);
145+
const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings);
123146

124147
return {
125148
start: Effect.void,
126149
ready: Effect.void,
127150
getSettings: Ref.get(currentSettingsRef),
128151
updateSettings: (patch) =>
129152
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-
),
153+
Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)),
154+
Effect.flatMap(normalizeServerSettings),
142155
Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)),
143156
),
144157
streamChanges: Stream.empty,
@@ -200,7 +213,10 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting
200213
}
201214

202215
// Values under these keys are compared as a whole — never stripped field-by-field.
203-
const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set(["textGenerationModelSelection"]);
216+
const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
217+
"automaticGitFetchInterval",
218+
"textGenerationModelSelection",
219+
]);
204220

205221
function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {
206222
if (Array.isArray(current) || Array.isArray(defaults)) {
@@ -431,7 +447,9 @@ const makeServerSettings = Effect.gen(function* () {
431447
});
432448

433449
const writeSettingsAtomically = (settings: ServerSettings) => {
434-
const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {};
450+
const encodedSettings = encodeServerSettingsSync(settings);
451+
const encodedDefaults = encodeServerSettingsSync(DEFAULT_SERVER_SETTINGS);
452+
const sparseSettings = stripDefaultServerSettings(encodedSettings, encodedDefaults) ?? {};
435453

436454
return writeFileStringAtomically({
437455
filePath: settingsPath,
@@ -533,16 +551,7 @@ const makeServerSettings = Effect.gen(function* () {
533551
current,
534552
applyServerSettingsPatch(current, patch),
535553
);
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-
);
554+
const next = yield* normalizeServerSettings(nextPersisted);
546555
yield* writeSettingsAtomically(next);
547556
yield* Cache.set(settingsCache, cacheKey, next);
548557
yield* emitChange(next);

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
4141
const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
4242
const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
4343
const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
44+
const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({
45+
SSH_ASKPASS_REQUIRE: "never",
46+
} satisfies NodeJS.ProcessEnv);
4447
const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
4548
const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
4649
const 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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { assert, it, describe } from "@effect/vitest";
22
import * as NodeServices from "@effect/platform-node/NodeServices";
33
import * as Deferred from "effect/Deferred";
4+
import * as Duration from "effect/Duration";
45
import * as Effect from "effect/Effect";
56
import * as Exit from "effect/Exit";
67
import * 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,

0 commit comments

Comments
 (0)